amicus 1.7.5 → 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 +66 -0
- package/LICENSE +2 -22
- package/README.md +26 -2
- package/bin/amicus.js +4 -4
- package/electron/ipc-guard.js +66 -0
- package/electron/main.js +40 -9
- package/electron/preload-content.js +37 -0
- package/electron/session-route.js +8 -1
- package/package.json +1 -1
- package/src/council/report-html.js +2 -1
- package/src/council/report.js +2 -1
- package/src/council/tally.js +18 -3
- package/src/jsonl-parser.js +31 -2
- package/src/mcp-server.js +55 -2
- package/src/opencode-client.js +48 -6
- package/src/project-root-allowlist.js +75 -0
- package/src/session-manager.js +7 -4
- package/src/session.js +0 -12
- package/src/sidecar/continue.js +29 -6
- package/src/sidecar/fanout-leg.js +42 -29
- package/src/sidecar/fanout-output.js +3 -5
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/interactive-mirror.js +14 -2
- package/src/sidecar/interactive.js +12 -0
- package/src/sidecar/resume.js +23 -7
- package/src/sidecar/setup-window.js +8 -0
- package/src/sidecar/start.js +16 -11
- package/src/utils/atomic-write.js +39 -0
- package/src/utils/error-doc.js +3 -0
- package/src/utils/format-duration.js +24 -0
- package/src/utils/mcp-discovery.js +6 -9
- package/src/utils/mcp-self-identity.js +69 -0
- package/src/utils/server-setup.js +4 -13
- package/src/utils/session-abort.js +3 -1
- package/src/utils/shared-server.js +64 -1
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);
|
|
@@ -55,6 +55,14 @@ async function launchSetupWindow() {
|
|
|
55
55
|
logger.debug('Setup window stderr', { data: chunk.trim() });
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
// A spawn failure (ENOENT/EACCES) emits 'error' and NEVER 'close'. Without
|
|
59
|
+
// this listener the Promise would hang forever and Node would treat the
|
|
60
|
+
// unhandled child 'error' as a crash. Resolve with a clear failure instead.
|
|
61
|
+
proc.on('error', (err) => {
|
|
62
|
+
logger.error('Setup window failed to spawn', { error: err.message });
|
|
63
|
+
resolve({ success: false, error: `Failed to start setup window: ${err.message}` });
|
|
64
|
+
});
|
|
65
|
+
|
|
58
66
|
proc.on('close', (code) => {
|
|
59
67
|
logger.info('Setup window closed', { code });
|
|
60
68
|
|
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() {
|
|
@@ -79,10 +80,12 @@ function createSessionMetadata(taskId, project, options) {
|
|
|
79
80
|
* @param {string} [options.clientType] - Parent client type for discovery
|
|
80
81
|
* @param {boolean} [options.noMcp] - Skip MCP inheritance from parent
|
|
81
82
|
* @param {string[]} [options.excludeMcp] - Server names to exclude
|
|
83
|
+
* @param {string} [options.projectDir] - Target project directory used to resolve
|
|
84
|
+
* a project-scoped opencode.json (NOT process.cwd() under Claude Code/MCP/Cowork)
|
|
82
85
|
* @returns {object|null} MCP server configs or null
|
|
83
86
|
*/
|
|
84
87
|
function buildMcpConfig(options) {
|
|
85
|
-
const { mcp, mcpConfig, clientType, noMcp, excludeMcp } = options;
|
|
88
|
+
const { mcp, mcpConfig, clientType, noMcp, excludeMcp, projectDir } = options;
|
|
86
89
|
let mcpServers = null;
|
|
87
90
|
|
|
88
91
|
// Layer 1: Discover parent MCPs (unless --no-mcp)
|
|
@@ -94,8 +97,10 @@ function buildMcpConfig(options) {
|
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
// Layer 2: File config (opencode.json) overrides discovered
|
|
98
|
-
|
|
100
|
+
// Layer 2: File config (opencode.json) overrides discovered.
|
|
101
|
+
// Resolve the project-scoped opencode.json against the target project dir,
|
|
102
|
+
// not process.cwd() (which differs under Claude Code/MCP/Cowork).
|
|
103
|
+
const fileConfig = loadMcpConfig(mcpConfig, projectDir);
|
|
99
104
|
if (fileConfig) {
|
|
100
105
|
mcpServers = mcpServers ? { ...mcpServers, ...fileConfig } : { ...fileConfig };
|
|
101
106
|
logger.debug('Loaded MCP config from file', { serverCount: Object.keys(fileConfig).length });
|
|
@@ -113,13 +118,11 @@ function buildMcpConfig(options) {
|
|
|
113
118
|
}
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
// Always exclude
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
logger.debug('Auto-excluded sidecar MCP (recursive spawn prevention)');
|
|
122
|
-
}
|
|
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); }
|
|
123
126
|
|
|
124
127
|
// Apply explicit exclusions
|
|
125
128
|
if (excludeMcp && Array.isArray(excludeMcp) && mcpServers) {
|
|
@@ -154,7 +157,9 @@ async function startSidecar(options) {
|
|
|
154
157
|
const effectiveSession = sessionId || session;
|
|
155
158
|
const effectiveProject = cwd || project;
|
|
156
159
|
const effectiveHeadless = noUi !== undefined ? noUi : headless;
|
|
157
|
-
const mcpServers = buildMcpConfig({
|
|
160
|
+
const mcpServers = buildMcpConfig({
|
|
161
|
+
mcp, mcpConfig, clientType: client, noMcp, excludeMcp, projectDir: effectiveProject
|
|
162
|
+
});
|
|
158
163
|
const taskId = options.taskId || generateTaskId();
|
|
159
164
|
const reasoning = thinking ? { effort: thinking } : undefined;
|
|
160
165
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file write helper.
|
|
3
|
+
*
|
|
4
|
+
* Writes data to a unique temp file alongside the target, then renames it into
|
|
5
|
+
* place. Rename is atomic on a single filesystem, so a crash mid-write leaves
|
|
6
|
+
* the original file intact rather than a truncated/corrupt one. This is the
|
|
7
|
+
* pattern already used by council/verdict.js, session-index.js, and
|
|
8
|
+
* model-catalog.js; this module shares it for metadata writes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Atomically write `data` to `filePath` via temp file + rename.
|
|
17
|
+
*
|
|
18
|
+
* The temp name is unique per write (pid + random suffix) so concurrent writers
|
|
19
|
+
* never collide on the same temp file. On any failure the temp file is cleaned
|
|
20
|
+
* up best-effort. Errors propagate to the caller.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} filePath - Destination path.
|
|
23
|
+
* @param {string|Buffer} data - Content to write.
|
|
24
|
+
* @param {{mode?: number}} [opts] - Write options; `mode` sets file permissions.
|
|
25
|
+
*/
|
|
26
|
+
function writeFileAtomic(filePath, data, opts = {}) {
|
|
27
|
+
const dir = path.dirname(filePath);
|
|
28
|
+
const base = path.basename(filePath);
|
|
29
|
+
const tmp = path.join(dir, `.${base}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
30
|
+
try {
|
|
31
|
+
fs.writeFileSync(tmp, data, { mode: opts.mode });
|
|
32
|
+
fs.renameSync(tmp, filePath); // atomic on a single filesystem
|
|
33
|
+
} catch (err) {
|
|
34
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort cleanup */ }
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { writeFileAtomic };
|
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
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// src/utils/format-duration.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module utils/format-duration
|
|
6
|
+
* Single ms->human duration formatter shared by the fanout and council
|
|
7
|
+
* renderers. Rolls minutes up ("1m5s" / "42s") and lets each caller pick its
|
|
8
|
+
* own null placeholder ("-" for fanout stdout, "—" for the council report).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format a millisecond duration as "1m5s" / "42s".
|
|
13
|
+
* @param {number|null|undefined} ms - Duration in milliseconds.
|
|
14
|
+
* @param {string} [empty='-'] - Placeholder for null/undefined input.
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function formatDuration(ms, empty = '-') {
|
|
18
|
+
if (ms === null || ms === undefined) { return empty; }
|
|
19
|
+
const s = Math.round(ms / 1000);
|
|
20
|
+
const m = Math.floor(s / 60);
|
|
21
|
+
return m > 0 ? `${m}m${s % 60}s` : `${s}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { formatDuration };
|
|
@@ -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 };
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Handles port management and cleanup for the OpenCode server.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const { execFileSync } = require('child_process');
|
|
8
7
|
const { logger } = require('./logger');
|
|
8
|
+
const { findListenerPid } = require('./port-pid');
|
|
9
9
|
|
|
10
10
|
const DEFAULT_PORT = 4096;
|
|
11
11
|
|
|
@@ -15,18 +15,9 @@ const DEFAULT_PORT = 4096;
|
|
|
15
15
|
* @returns {number|null} PID or null if not in use
|
|
16
16
|
*/
|
|
17
17
|
function getPortPid(port) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
encoding: 'utf8',
|
|
22
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
23
|
-
});
|
|
24
|
-
const pid = parseInt(result.trim(), 10);
|
|
25
|
-
return isNaN(pid) ? null : pid;
|
|
26
|
-
} catch {
|
|
27
|
-
// lsof returns non-zero if no process found
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
18
|
+
// Delegate to the cross-platform lookup (netstat on Windows, lsof elsewhere)
|
|
19
|
+
// so the port-in-use check and kill path work off Unix too.
|
|
20
|
+
return findListenerPid(port);
|
|
30
21
|
}
|
|
31
22
|
|
|
32
23
|
/**
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
|
+
const { writeFileAtomic } = require('./atomic-write');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Synchronously write a terminal status to a session's metadata. Best-effort: never throws.
|
|
@@ -28,7 +29,8 @@ function markTerminal(sessionDir, status, reason) {
|
|
|
28
29
|
meta.status = status;
|
|
29
30
|
meta.reason = reason;
|
|
30
31
|
meta[status === 'aborted' ? 'abortedAt' : 'completedAt'] = new Date().toISOString();
|
|
31
|
-
|
|
32
|
+
// Atomic (temp + rename): a crash mid-write must not corrupt the marker.
|
|
33
|
+
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
32
34
|
return true;
|
|
33
35
|
} catch {
|
|
34
36
|
return false;
|
|
@@ -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
|
/**
|
|
@@ -73,6 +84,7 @@ class SharedServerManager {
|
|
|
73
84
|
this.server = server;
|
|
74
85
|
this.client = client;
|
|
75
86
|
this._starting = null;
|
|
87
|
+
this._wireCrashListener(server);
|
|
76
88
|
this._serverWatchdog = new IdleWatchdog({
|
|
77
89
|
mode: 'server',
|
|
78
90
|
onTimeout: () => {
|
|
@@ -162,6 +174,8 @@ class SharedServerManager {
|
|
|
162
174
|
this._serverWatchdog.cancel();
|
|
163
175
|
this._serverWatchdog = null;
|
|
164
176
|
}
|
|
177
|
+
this._stopCrashPoll();
|
|
178
|
+
if (this._restartTimer) { clearTimeout(this._restartTimer); this._restartTimer = null; }
|
|
165
179
|
if (this.server) {
|
|
166
180
|
this.server.close();
|
|
167
181
|
this.server = null;
|
|
@@ -169,19 +183,68 @@ class SharedServerManager {
|
|
|
169
183
|
}
|
|
170
184
|
}
|
|
171
185
|
|
|
186
|
+
/**
|
|
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.
|
|
191
|
+
* @param {object} server - Server handle returned by _doStartServer
|
|
192
|
+
*/
|
|
193
|
+
_wireCrashListener(server) {
|
|
194
|
+
const emitter = (server && typeof server.on === 'function')
|
|
195
|
+
? server
|
|
196
|
+
: (server && server.process && typeof server.process.on === 'function')
|
|
197
|
+
? server.process
|
|
198
|
+
: null;
|
|
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');
|
|
212
|
+
}
|
|
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);
|
|
224
|
+
}
|
|
225
|
+
}, interval);
|
|
226
|
+
if (this._crashPoll.unref) { this._crashPoll.unref(); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_stopCrashPoll() {
|
|
230
|
+
if (this._crashPoll) { clearInterval(this._crashPoll); this._crashPoll = null; }
|
|
231
|
+
}
|
|
232
|
+
|
|
172
233
|
/**
|
|
173
234
|
* Handle a server crash: log, notify sessions, then schedule restart.
|
|
174
235
|
*
|
|
175
236
|
* @param {number} exitCode - Process exit code from the crashed server
|
|
176
237
|
*/
|
|
177
238
|
_onServerCrash(exitCode) {
|
|
239
|
+
this._stopCrashPoll();
|
|
178
240
|
this.logger.error?.('Shared server crashed', { exitCode });
|
|
179
241
|
for (const [id] of this._sessionWatchdogs) {
|
|
180
242
|
this.logger.warn?.('Session interrupted by server crash', { sessionId: id });
|
|
181
243
|
}
|
|
182
244
|
this.server = null;
|
|
183
245
|
this.client = null;
|
|
184
|
-
setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
|
|
246
|
+
this._restartTimer = setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
|
|
247
|
+
if (this._restartTimer.unref) { this._restartTimer.unref(); }
|
|
185
248
|
}
|
|
186
249
|
|
|
187
250
|
/**
|