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.
@@ -1,19 +1,40 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.6",
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": { "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,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) 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).
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.6",
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",
@@ -108,7 +108,10 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
108
108
  return sessionDir;
109
109
  }
110
110
 
111
- /** Continue from a previous sidecar session - Spec Reference: §4.4, §8.5 */
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
- const result = await runHeadless(
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
- const result = await runInteractive(
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
- // Finalize session. Interactive mode legitimately returns an empty summary,
209
- // so pass status explicitly to stay out of the #36 empty-summary guard.
210
- finalizeSession(sessionDir, summary, project, meta, { status: 'complete' });
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 = {
@@ -110,7 +110,10 @@ function updateSessionStatus(sessionDir, status) {
110
110
  return meta;
111
111
  }
112
112
 
113
- /** Resume a previous sidecar session - Spec Reference: §4.3, §8.3 */
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
- const result = await runHeadless(
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
- const result = await runInteractive(
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
- // 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' });
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);
@@ -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 the sidecar itself to prevent recursive spawning.
121
- // When launched from Cowork, the discovered MCP list includes "sidecar"
122
- // which would cause an infinite spawn loop.
123
- if (mcpServers && mcpServers.sidecar) {
124
- delete mcpServers.sidecar;
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) {
@@ -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
- const merged = { ...pluginServers, ...claudeJsonServers };
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 so an unexpected
175
- * exit triggers _onServerCrash (and the restart machinery). The OpenCode
176
- * server handle is a plain wrapper; it may surface lifecycle events either on
177
- * itself or on an underlying child `process`. Attach to whichever is an event
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 (!emitter || emitter._amicusCrashWired) {
189
- return;
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
- emitter._amicusCrashWired = true;
192
- const onExit = (code) => {
193
- // Ignore exits from a stale handle we have already replaced/closed.
194
- if (this.server !== server) {
195
- return;
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
- this._onServerCrash(code);
198
- };
199
- emitter.on('exit', onExit);
200
- emitter.on('close', onExit);
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
  /**