amicus 1.7.6 → 1.7.7
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 +27 -6
- package/CHANGELOG.md +27 -0
- package/LICENSE +2 -22
- package/README.md +26 -2
- package/bin/amicus.js +4 -4
- package/package.json +1 -1
- package/src/sidecar/continue.js +24 -6
- package/src/sidecar/resume.js +23 -7
- package/src/sidecar/start.js +6 -7
- package/src/utils/error-doc.js +3 -0
- package/src/utils/mcp-discovery.js +6 -9
- package/src/utils/mcp-self-identity.js +69 -0
- package/src/utils/shared-server.js +50 -18
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
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": {
|
|
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": [
|
|
11
|
-
|
|
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": [
|
|
16
|
-
|
|
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,33 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.7] - 2026-07-01
|
|
9
|
+
|
|
10
|
+
Correctness patch from the 2026-07-01 full product review (multi-agent review, every finding adversarially
|
|
11
|
+
verified against source), executed subagent-driven with per-task adversarial review plus a final whole-branch review.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Terminal errors now show their actionable hint.** Human-mode errors printed only the message while `--json`
|
|
15
|
+
carried a `hint` field; the hint now prints on a second ` → …` line. Budget-gate refusals finally tell you the
|
|
16
|
+
offending model, the threshold, and the `--max-cost` / `--no-cost-gate` overrides.
|
|
17
|
+
- **Spawned sidecars no longer inherit Amicus's own MCP server.** The recursive-spawn guard only excluded a server
|
|
18
|
+
literally named `sidecar`, but the product registers as `amicus` — so every child model inherited the full
|
|
19
|
+
Amicus toolset and could spawn recursively. Children now exclude any inherited entry that *is* Amicus, matched
|
|
20
|
+
by name **or** by what the command actually runs (`amicus mcp`, `npx … amicus … mcp`, a `bin/amicus.js … mcp`
|
|
21
|
+
path). Note: this strip has no opt-out — a deliberately configured nested Amicus MCP entry is also removed from
|
|
22
|
+
spawned children.
|
|
23
|
+
- **Shared-server crash detection actually works.** The crash/restart machinery listened on an event emitter the
|
|
24
|
+
real server handle never exposed, so it was dead code — a dead engine silently degraded every later session.
|
|
25
|
+
A pid liveness poll now drives detection and restart, and shutting down during the restart backoff cancels the
|
|
26
|
+
pending restart instead of spawning a server nobody asked for.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **`amicus continue` and `amicus resume` now report failures truthfully** (behavior change): error exits 1,
|
|
30
|
+
timeout exits 2, abort exits 130/143/2 — previously both always exited 0 and recorded the session as
|
|
31
|
+
`complete` even when the model errored or timed out. The session record now finalizes `error`/`timed-out`
|
|
32
|
+
accordingly (interactive sessions that legitimately end with an empty summary still finalize `complete`).
|
|
33
|
+
Scripts that gated on exit code 0 for these verbs will now see real failures.
|
|
34
|
+
|
|
8
35
|
## [1.7.6] - 2026-07-01
|
|
9
36
|
|
|
10
37
|
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)
|
|
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
|
-
|
|
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).
|
package/bin/amicus.js
CHANGED
|
@@ -95,10 +95,10 @@ async function main() {
|
|
|
95
95
|
await handleList(args);
|
|
96
96
|
break;
|
|
97
97
|
case 'resume':
|
|
98
|
-
await handleResume(args);
|
|
98
|
+
exitCode = await handleResume(args);
|
|
99
99
|
break;
|
|
100
100
|
case 'continue':
|
|
101
|
-
await handleContinue(args);
|
|
101
|
+
exitCode = await handleContinue(args);
|
|
102
102
|
break;
|
|
103
103
|
case 'read':
|
|
104
104
|
await handleRead(args);
|
|
@@ -188,7 +188,7 @@ async function handleResume(args) {
|
|
|
188
188
|
|
|
189
189
|
const { resumeSidecar } = require('../src/index');
|
|
190
190
|
|
|
191
|
-
await resumeSidecar({
|
|
191
|
+
return await resumeSidecar({
|
|
192
192
|
taskId,
|
|
193
193
|
project: args.cwd,
|
|
194
194
|
headless: args['no-ui'],
|
|
@@ -242,7 +242,7 @@ async function handleContinue(args) {
|
|
|
242
242
|
|
|
243
243
|
const { continueSidecar } = require('../src/index');
|
|
244
244
|
|
|
245
|
-
await continueSidecar({
|
|
245
|
+
return await continueSidecar({
|
|
246
246
|
taskId,
|
|
247
247
|
newTaskId: args['task-id'],
|
|
248
248
|
briefing: args.prompt || args.briefing,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
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",
|
package/src/sidecar/continue.js
CHANGED
|
@@ -108,7 +108,10 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
|
108
108
|
return sessionDir;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Continue from a previous sidecar session - Spec Reference: §4.4, §8.5
|
|
113
|
+
* @returns {Promise<number>} process exit code
|
|
114
|
+
*/
|
|
112
115
|
async function continueSidecar(options) {
|
|
113
116
|
const {
|
|
114
117
|
taskId: oldTaskId,
|
|
@@ -171,10 +174,11 @@ async function continueSidecar(options) {
|
|
|
171
174
|
const heartbeat = createHeartbeat();
|
|
172
175
|
|
|
173
176
|
let summary;
|
|
177
|
+
let result;
|
|
174
178
|
|
|
175
179
|
try {
|
|
176
180
|
if (headless) {
|
|
177
|
-
|
|
181
|
+
result = await runHeadless(
|
|
178
182
|
model, systemPrompt, userMessage, newTaskId, project,
|
|
179
183
|
timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
|
|
180
184
|
);
|
|
@@ -185,7 +189,7 @@ async function continueSidecar(options) {
|
|
|
185
189
|
if (result.error) { logger.error('Continuation task error', { taskId: newTaskId, error: result.error }); }
|
|
186
190
|
} else {
|
|
187
191
|
logger.info('Launching interactive continue', { taskId: newTaskId, model });
|
|
188
|
-
|
|
192
|
+
result = await runInteractive(
|
|
189
193
|
model, systemPrompt, userMessage, newTaskId, project,
|
|
190
194
|
{ agent: effectiveAgent, mcp: mcpServers }
|
|
191
195
|
);
|
|
@@ -205,9 +209,23 @@ async function continueSidecar(options) {
|
|
|
205
209
|
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
206
210
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
207
211
|
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
212
|
+
// Map the run result to the canonical terminal status + exit code — mirrors
|
|
213
|
+
// start.js; resolveTerminalState is the single source of truth. Passing the
|
|
214
|
+
// status explicitly also preserves the interactive empty-summary carve-out:
|
|
215
|
+
// a clean interactive run finalizes 'complete' without tripping the #36
|
|
216
|
+
// empty-summary guard.
|
|
217
|
+
const { resolveTerminalState } = require('./session-finalize');
|
|
218
|
+
const terminal = resolveTerminalState(result);
|
|
219
|
+
if (terminal.status === 'error') {
|
|
220
|
+
meta.status = 'error';
|
|
221
|
+
meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
222
|
+
meta.completedAt = new Date().toISOString();
|
|
223
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
224
|
+
logger.error('Continuation completed with error', { taskId: newTaskId, error: meta.reason });
|
|
225
|
+
} else {
|
|
226
|
+
finalizeSession(sessionDir, summary, project, meta, { status: terminal.status });
|
|
227
|
+
}
|
|
228
|
+
return terminal.exitCode;
|
|
211
229
|
}
|
|
212
230
|
|
|
213
231
|
module.exports = {
|
package/src/sidecar/resume.js
CHANGED
|
@@ -110,7 +110,10 @@ function updateSessionStatus(sessionDir, status) {
|
|
|
110
110
|
return meta;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Resume a previous sidecar session - Spec Reference: §4.3, §8.3
|
|
115
|
+
* @returns {Promise<number>} process exit code
|
|
116
|
+
*/
|
|
114
117
|
async function resumeSidecar(options) {
|
|
115
118
|
const {
|
|
116
119
|
taskId, project = process.cwd(), headless = false, timeout = 15,
|
|
@@ -163,6 +166,7 @@ async function resumeSidecar(options) {
|
|
|
163
166
|
heartbeat = createHeartbeat();
|
|
164
167
|
|
|
165
168
|
let summary;
|
|
169
|
+
let result;
|
|
166
170
|
const effectiveAgent = metadata.agent || 'Build';
|
|
167
171
|
|
|
168
172
|
// Load conversation for both paths (interactive already did this, headless didn't)
|
|
@@ -173,7 +177,7 @@ async function resumeSidecar(options) {
|
|
|
173
177
|
|
|
174
178
|
if (headless) {
|
|
175
179
|
const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
|
|
176
|
-
|
|
180
|
+
result = await runHeadless(
|
|
177
181
|
metadata.model, resumePrompt, userMessage,
|
|
178
182
|
taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
|
|
179
183
|
);
|
|
@@ -184,7 +188,7 @@ async function resumeSidecar(options) {
|
|
|
184
188
|
} else {
|
|
185
189
|
logger.info('Launching interactive resume', { taskId, model: metadata.model });
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
result = await runInteractive(
|
|
188
192
|
metadata.model, resumePrompt, metadata.briefing || '',
|
|
189
193
|
taskId, project,
|
|
190
194
|
{
|
|
@@ -202,10 +206,22 @@ async function resumeSidecar(options) {
|
|
|
202
206
|
// Output summary
|
|
203
207
|
outputSummary(summary);
|
|
204
208
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
+
// Map the run result to the canonical terminal status + exit code —
|
|
210
|
+
// mirrors start.js. Explicit status preserves the interactive
|
|
211
|
+
// empty-summary carve-out (the #36 guard never re-classifies it).
|
|
212
|
+
const { resolveTerminalState } = require('./session-finalize');
|
|
213
|
+
const terminal = resolveTerminalState(result);
|
|
214
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
215
|
+
if (terminal.status === 'error') {
|
|
216
|
+
updatedMetadata.status = 'error';
|
|
217
|
+
updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
218
|
+
updatedMetadata.completedAt = new Date().toISOString();
|
|
219
|
+
fs.writeFileSync(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
|
|
220
|
+
logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
|
|
221
|
+
} else {
|
|
222
|
+
finalizeSession(sessionDir, summary, project, updatedMetadata, { status: terminal.status });
|
|
223
|
+
}
|
|
224
|
+
return terminal.exitCode; // finally below still releases the lock first
|
|
209
225
|
} finally {
|
|
210
226
|
if (heartbeat) { heartbeat.stop(); }
|
|
211
227
|
releaseLock(sessionDir);
|
package/src/sidecar/start.js
CHANGED
|
@@ -23,6 +23,7 @@ const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
|
23
23
|
const { loadMcpConfig, parseMcpSpec } = require('../opencode-client');
|
|
24
24
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
25
25
|
const { discoverParentMcps } = require('../utils/mcp-discovery');
|
|
26
|
+
const { stripSelfMcpEntries } = require('../utils/mcp-self-identity');
|
|
26
27
|
|
|
27
28
|
/** Generate a unique 8-character hex task ID */
|
|
28
29
|
function generateTaskId() {
|
|
@@ -117,13 +118,11 @@ function buildMcpConfig(options) {
|
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
// Always exclude
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
logger.debug('Auto-excluded sidecar MCP (recursive spawn prevention)');
|
|
126
|
-
}
|
|
121
|
+
// Always exclude amicus itself — under ANY registered name or aliased
|
|
122
|
+
// invocation — to prevent recursive spawning. When launched from Cowork or
|
|
123
|
+
// Claude Code the discovered list includes 'amicus'/'sidecar' (and possibly
|
|
124
|
+
// a user alias), which would cause an infinite spawn loop.
|
|
125
|
+
if (mcpServers) { stripSelfMcpEntries(mcpServers, logger); }
|
|
127
126
|
|
|
128
127
|
// Apply explicit exclusions
|
|
129
128
|
if (excludeMcp && Array.isArray(excludeMcp) && mcpServers) {
|
package/src/utils/error-doc.js
CHANGED
|
@@ -48,6 +48,9 @@ function failJson(useJson, { code, message, hint = null, command = null }) {
|
|
|
48
48
|
process.stdout.write(JSON.stringify(buildErrorDoc({ code, message, hint, command }), null, 2) + '\n');
|
|
49
49
|
} else {
|
|
50
50
|
process.stderr.write(message + '\n');
|
|
51
|
+
// Parity with --json (whose envelope carries error.hint): surface the
|
|
52
|
+
// actionable hint to humans too, in doctor's arrow style.
|
|
53
|
+
if (hint) { process.stderr.write(` → ${hint}\n`); }
|
|
51
54
|
}
|
|
52
55
|
return 1;
|
|
53
56
|
}
|
|
@@ -12,6 +12,7 @@ const fs = require('fs');
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const os = require('os');
|
|
14
14
|
const { logger } = require('./logger');
|
|
15
|
+
const { stripSelfMcpEntries } = require('./mcp-self-identity');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Normalize .mcp.json to a flat { name: config } map.
|
|
@@ -70,15 +71,13 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
|
|
|
70
71
|
const settingsPath = path.join(baseDir, 'settings.json');
|
|
71
72
|
if (!fs.existsSync(settingsPath)) {
|
|
72
73
|
// No settings.json — skip plugin discovery, may still have claude.json servers
|
|
73
|
-
const merged = { ...claudeJsonServers };
|
|
74
|
-
delete merged.sidecar;
|
|
74
|
+
const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
|
|
75
75
|
return Object.keys(merged).length > 0 ? merged : null;
|
|
76
76
|
}
|
|
77
77
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
78
78
|
const enabledPlugins = settings.enabledPlugins;
|
|
79
79
|
if (!enabledPlugins || typeof enabledPlugins !== 'object') {
|
|
80
|
-
const merged = { ...claudeJsonServers };
|
|
81
|
-
delete merged.sidecar;
|
|
80
|
+
const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
|
|
82
81
|
return Object.keys(merged).length > 0 ? merged : null;
|
|
83
82
|
}
|
|
84
83
|
|
|
@@ -133,11 +132,9 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
|
|
|
133
132
|
logger.debug('Failed to read Claude Code settings', { error: err.message });
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
// Merge: plugin servers first, then claude.json overwrites (higher priority)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// Always exclude sidecar itself to prevent recursive spawning
|
|
140
|
-
delete merged.sidecar;
|
|
135
|
+
// Merge: plugin servers first, then claude.json overwrites (higher priority).
|
|
136
|
+
// Recursive-spawn guard: drop every entry that resolves to amicus itself.
|
|
137
|
+
const merged = stripSelfMcpEntries({ ...pluginServers, ...claudeJsonServers }, logger);
|
|
141
138
|
|
|
142
139
|
return Object.keys(merged).length > 0 ? merged : null;
|
|
143
140
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module mcp-self-identity
|
|
5
|
+
* Recursive-spawn guard. A child sidecar that inherits an MCP entry launching
|
|
6
|
+
* amicus itself would spawn amicus inside amicus, forever. The shipped server
|
|
7
|
+
* registers as 'amicus' (scripts/postinstall.js, .claude-plugin/plugin.json)
|
|
8
|
+
* plus a deprecated 'sidecar' shim — and users can alias it under ANY name —
|
|
9
|
+
* so we exclude both reserved names AND any entry whose command+args resolve
|
|
10
|
+
* to an amicus MCP invocation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Server names amicus registers itself under. */
|
|
14
|
+
const SELF_MCP_NAMES = Object.freeze(['amicus', 'sidecar']);
|
|
15
|
+
|
|
16
|
+
/** Shipped bin aliases (package.json "bin") → ./bin/amicus.js */
|
|
17
|
+
const SELF_BIN_NAMES = new Set(['amicus', 'am', 'sidecar', 'claude-sidecar']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalize one command/arg token for identity matching: lower-case, forward
|
|
21
|
+
* slashes, basename, strip a trailing .exe/.cmd/.js, strip an @version spec.
|
|
22
|
+
* 'C:\\x\\bin\\amicus.js' → 'amicus'; 'amicus@latest' → 'amicus'; 'npx' → 'npx'.
|
|
23
|
+
* @param {unknown} token
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function normalizeToken(token) {
|
|
27
|
+
const t = String(token).toLowerCase().replace(/\\/g, '/');
|
|
28
|
+
const base = t.includes('/') ? t.slice(t.lastIndexOf('/') + 1) : t;
|
|
29
|
+
return base.replace(/\.(exe|cmd|js)$/, '').replace(/@[^@]*$/, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when this MCP server config would launch amicus's own MCP server:
|
|
34
|
+
* some non-flag token resolves to an amicus binary/package and a LATER token
|
|
35
|
+
* is 'mcp'. URL-only (command-less) configs are never self.
|
|
36
|
+
* @param {{command?:string, args?:unknown[]}|null|undefined} config
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isAmicusMcpConfig(config) {
|
|
40
|
+
if (!config || typeof config !== 'object' || !config.command) { return false; }
|
|
41
|
+
const tokens = [config.command, ...(Array.isArray(config.args) ? config.args : [])].map(String);
|
|
42
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
43
|
+
if (tokens[i].startsWith('-')) { continue; } // flags (-y, --yes) are never the binary
|
|
44
|
+
if (SELF_BIN_NAMES.has(normalizeToken(tokens[i]))) {
|
|
45
|
+
return tokens.slice(i + 1).some((t) => String(t).toLowerCase() === 'mcp');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Delete every self entry (reserved name OR command identity) from an
|
|
53
|
+
* mcpServers map. Mutates and returns the same object.
|
|
54
|
+
* @param {object|null|undefined} mcpServers
|
|
55
|
+
* @param {{debug?:Function}} [log]
|
|
56
|
+
* @returns {object|null|undefined}
|
|
57
|
+
*/
|
|
58
|
+
function stripSelfMcpEntries(mcpServers, log) {
|
|
59
|
+
if (!mcpServers || typeof mcpServers !== 'object') { return mcpServers; }
|
|
60
|
+
for (const name of Object.keys(mcpServers)) {
|
|
61
|
+
if (SELF_MCP_NAMES.includes(name) || isAmicusMcpConfig(mcpServers[name])) {
|
|
62
|
+
delete mcpServers[name];
|
|
63
|
+
if (log && log.debug) { log.debug('Auto-excluded amicus MCP entry (recursive spawn prevention)', { name }); }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return mcpServers;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { SELF_MCP_NAMES, isAmicusMcpConfig, stripSelfMcpEntries, normalizeToken };
|
|
@@ -11,6 +11,7 @@ const { getCompatEnv } = require('./env-compat');
|
|
|
11
11
|
const MAX_RESTARTS = 3;
|
|
12
12
|
const RESTART_WINDOW = 5 * 60 * 1000;
|
|
13
13
|
const RESTART_BACKOFF = 2000;
|
|
14
|
+
const CRASH_POLL_INTERVAL = 5000; // ms; env-tunable via AMICUS_CRASH_POLL_MS
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* SharedServerManager - manages a single shared OpenCode server for MCP sessions.
|
|
@@ -45,6 +46,16 @@ class SharedServerManager {
|
|
|
45
46
|
|
|
46
47
|
/** @type {number[]} Timestamps of recent restart attempts */
|
|
47
48
|
this._restartTimestamps = [];
|
|
49
|
+
|
|
50
|
+
/** @type {NodeJS.Timeout|null} goPid liveness poll (H7) */
|
|
51
|
+
this._crashPoll = null;
|
|
52
|
+
|
|
53
|
+
/** @type {NodeJS.Timeout|null} Pending crash-restart backoff timer */
|
|
54
|
+
this._restartTimer = null;
|
|
55
|
+
|
|
56
|
+
/** Pid-liveness probe (test seam). Lazy default keeps construction light. */
|
|
57
|
+
this._isProcessAlive = options.isProcessAlive
|
|
58
|
+
|| ((pid) => require('../sidecar/session-utils').isProcessAlive(pid));
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
|
@@ -163,6 +174,8 @@ class SharedServerManager {
|
|
|
163
174
|
this._serverWatchdog.cancel();
|
|
164
175
|
this._serverWatchdog = null;
|
|
165
176
|
}
|
|
177
|
+
this._stopCrashPoll();
|
|
178
|
+
if (this._restartTimer) { clearTimeout(this._restartTimer); this._restartTimer = null; }
|
|
166
179
|
if (this.server) {
|
|
167
180
|
this.server.close();
|
|
168
181
|
this.server = null;
|
|
@@ -171,12 +184,10 @@ class SharedServerManager {
|
|
|
171
184
|
}
|
|
172
185
|
|
|
173
186
|
/**
|
|
174
|
-
* Wire crash detection onto a freshly started server handle
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* emitter, guarding against double-wiring across restarts.
|
|
179
|
-
*
|
|
187
|
+
* Wire crash detection onto a freshly started server handle. The REAL handle
|
|
188
|
+
* from buildServerHandle is { url, goPid, close } — it surfaces NO lifecycle
|
|
189
|
+
* events, so the emitter path alone was dead code (H7). Detection now polls
|
|
190
|
+
* the Go engine pid; emitter wiring is kept for handles that do expose events.
|
|
180
191
|
* @param {object} server - Server handle returned by _doStartServer
|
|
181
192
|
*/
|
|
182
193
|
_wireCrashListener(server) {
|
|
@@ -185,19 +196,38 @@ class SharedServerManager {
|
|
|
185
196
|
: (server && server.process && typeof server.process.on === 'function')
|
|
186
197
|
? server.process
|
|
187
198
|
: null;
|
|
188
|
-
if (
|
|
189
|
-
|
|
199
|
+
if (emitter && !emitter._amicusCrashWired) {
|
|
200
|
+
emitter._amicusCrashWired = true;
|
|
201
|
+
const onExit = (code) => {
|
|
202
|
+
if (this.server !== server) { return; } // stale handle already replaced/closed
|
|
203
|
+
this._onServerCrash(code);
|
|
204
|
+
};
|
|
205
|
+
emitter.on('exit', onExit);
|
|
206
|
+
emitter.on('close', onExit);
|
|
207
|
+
}
|
|
208
|
+
if (server && server.goPid) {
|
|
209
|
+
this._startCrashPoll(server);
|
|
210
|
+
} else if (!emitter) {
|
|
211
|
+
this.logger.debug?.('Server handle has no goPid and no emitter — crash detection unavailable');
|
|
190
212
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Poll the Go engine pid; pid death IS the crash signal (H7). */
|
|
216
|
+
_startCrashPoll(server) {
|
|
217
|
+
this._stopCrashPoll();
|
|
218
|
+
const interval = Number(getCompatEnv('CRASH_POLL_MS')) || CRASH_POLL_INTERVAL;
|
|
219
|
+
this._crashPoll = setInterval(() => {
|
|
220
|
+
if (this.server !== server) { this._stopCrashPoll(); return; }
|
|
221
|
+
if (!this._isProcessAlive(server.goPid)) {
|
|
222
|
+
this._stopCrashPoll();
|
|
223
|
+
this._onServerCrash(null);
|
|
196
224
|
}
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
225
|
+
}, interval);
|
|
226
|
+
if (this._crashPoll.unref) { this._crashPoll.unref(); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_stopCrashPoll() {
|
|
230
|
+
if (this._crashPoll) { clearInterval(this._crashPoll); this._crashPoll = null; }
|
|
201
231
|
}
|
|
202
232
|
|
|
203
233
|
/**
|
|
@@ -206,13 +236,15 @@ class SharedServerManager {
|
|
|
206
236
|
* @param {number} exitCode - Process exit code from the crashed server
|
|
207
237
|
*/
|
|
208
238
|
_onServerCrash(exitCode) {
|
|
239
|
+
this._stopCrashPoll();
|
|
209
240
|
this.logger.error?.('Shared server crashed', { exitCode });
|
|
210
241
|
for (const [id] of this._sessionWatchdogs) {
|
|
211
242
|
this.logger.warn?.('Session interrupted by server crash', { sessionId: id });
|
|
212
243
|
}
|
|
213
244
|
this.server = null;
|
|
214
245
|
this.client = null;
|
|
215
|
-
setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
|
|
246
|
+
this._restartTimer = setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
|
|
247
|
+
if (this._restartTimer.unref) { this._restartTimer.unref(); }
|
|
216
248
|
}
|
|
217
249
|
|
|
218
250
|
/**
|