amicus 1.7.7 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +38 -0
- package/README.md +12 -3
- package/bin/amicus.js +5 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +72 -23
- package/src/cli-handlers-doctor.js +43 -0
- package/src/cli-handlers-status.js +76 -0
- package/src/cli-handlers.js +32 -0
- package/src/cli.js +8 -0
- package/src/mcp-server.js +124 -21
- package/src/mcp-tools.js +28 -1
- package/src/mcp-wait.js +163 -0
- package/src/sidecar/conversation-mirror.js +17 -4
- package/src/sidecar/interactive-abort.js +112 -0
- package/src/sidecar/interactive.js +32 -2
- package/src/sidecar/progress-fields.js +60 -0
- package/src/sidecar/progress.js +52 -48
- package/src/utils/abort-coordinator.js +91 -0
- package/src/utils/legacy-mcp-migration.js +119 -0
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/remediation-hints.js +8 -0
|
@@ -4,17 +4,20 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
7
8
|
const { spawn } = require('child_process');
|
|
8
9
|
|
|
9
10
|
const { startOpenCodeServer } = require('./session-utils');
|
|
10
|
-
const { createSession, sendPromptAsync, getMessages } = require('../opencode-client');
|
|
11
|
+
const { createSession, sendPromptAsync, getMessages, abortSession } = require('../opencode-client');
|
|
11
12
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
12
13
|
const { logger } = require('../utils/logger');
|
|
13
14
|
const { getCompatEnv } = require('../utils/env-compat');
|
|
14
15
|
const { startInteractiveMirror } = require('./interactive-mirror');
|
|
16
|
+
const { startAbortWatch, markResultAborted, readAbortedMarker } = require('./interactive-abort');
|
|
15
17
|
const { getSessionDir } = require('../session-manager');
|
|
16
18
|
const { canonicalProjectPath } = require('../utils/project-path');
|
|
17
19
|
const { ensureElectron } = require('./electron-ensure');
|
|
20
|
+
const { writeProgress } = require('./progress');
|
|
18
21
|
|
|
19
22
|
/** Resolve the Electron binary path ONLY when the exe actually exists on disk.
|
|
20
23
|
* #54: path.txt surviving (require('electron') resolving) is NOT enough — a
|
|
@@ -115,6 +118,14 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
115
118
|
|
|
116
119
|
const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client } = options;
|
|
117
120
|
|
|
121
|
+
// F6c: mirror headless's lifecycle stages (best-effort — a write failure must
|
|
122
|
+
// never break the GUI) so the heartbeat/status never read "Starting up...".
|
|
123
|
+
const sessionDir = getSessionDir(project, taskId);
|
|
124
|
+
const progressStage = (stage) => {
|
|
125
|
+
try { fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 }); writeProgress(sessionDir, stage); } catch { /* best-effort */ }
|
|
126
|
+
};
|
|
127
|
+
progressStage('initializing');
|
|
128
|
+
|
|
118
129
|
// Scope the OpenCode session to the project/--cwd (#45). The SDK Session
|
|
119
130
|
// object echoes a `directory`, but createSession() returns only the id, so we
|
|
120
131
|
// use the canonicalized --cwd CONSISTENTLY for BOTH the create scope and the
|
|
@@ -133,6 +144,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
133
144
|
});
|
|
134
145
|
ocClient = result.client;
|
|
135
146
|
server = result.server;
|
|
147
|
+
progressStage('server_ready');
|
|
136
148
|
} catch (error) {
|
|
137
149
|
logger.error('Failed to start OpenCode server', { error: error.message });
|
|
138
150
|
return {
|
|
@@ -148,9 +160,11 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
148
160
|
// Resume: reconnect to existing OpenCode session
|
|
149
161
|
sessionId = opencodeSessionId;
|
|
150
162
|
logger.info('Reconnecting to existing session', { sessionId });
|
|
163
|
+
progressStage('session_created');
|
|
151
164
|
} else {
|
|
152
165
|
// New session: create and send initial prompt, scoped to --cwd (#45).
|
|
153
166
|
sessionId = await createSession(ocClient, sessionDirectory);
|
|
167
|
+
progressStage('session_created');
|
|
154
168
|
|
|
155
169
|
// System prompt is set on agent config (hidden from UI).
|
|
156
170
|
// Do NOT pass system here — promptAsync's system field is visible in the UI.
|
|
@@ -165,6 +179,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
165
179
|
if (reasoning) { promptOptions.reasoning = reasoning; }
|
|
166
180
|
|
|
167
181
|
await sendPromptAsync(ocClient, sessionId, promptOptions);
|
|
182
|
+
progressStage('prompt_sent');
|
|
168
183
|
}
|
|
169
184
|
logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
|
|
170
185
|
} catch (error) {
|
|
@@ -199,13 +214,21 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
199
214
|
onActivity: () => watchdog.touch(),
|
|
200
215
|
});
|
|
201
216
|
|
|
202
|
-
const sessionDir = getSessionDir(project, taskId);
|
|
203
217
|
const mirror = startInteractiveMirror({
|
|
204
218
|
getMessages: () => getMessages(ocClient, sessionId, sessionDirectory),
|
|
205
219
|
sessionDir,
|
|
206
220
|
onActivity: () => watchdog.touch(),
|
|
207
221
|
});
|
|
208
222
|
|
|
223
|
+
// Phase 3: external aborts (CLI `amicus abort` / MCP amicus_abort) write a
|
|
224
|
+
// metadata marker; nothing in the GUI path watched it before. On marker:
|
|
225
|
+
// server-side abort + SIGTERM Electron; the close handler finishes teardown.
|
|
226
|
+
const abortWatch = startAbortWatch({
|
|
227
|
+
sessionDir,
|
|
228
|
+
abortOpenCodeSession: () => abortSession(ocClient, sessionId, sessionDirectory),
|
|
229
|
+
killElectron: () => killIfAlive(electronProcess),
|
|
230
|
+
});
|
|
231
|
+
|
|
209
232
|
return new Promise((resolve, _reject) => {
|
|
210
233
|
// Prefer the path ensureElectron() resolved: a same-process first-use
|
|
211
234
|
// provision can leave require('electron') cached as a stale null (#55).
|
|
@@ -248,6 +271,13 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
248
271
|
process.removeListener('SIGTERM', killChildOnParentDeath);
|
|
249
272
|
watchdog.cancel();
|
|
250
273
|
activityPoller.stop();
|
|
274
|
+
abortWatch.stop();
|
|
275
|
+
// Close a race the poll-based watch can miss: the marker can land and
|
|
276
|
+
// Electron can exit naturally before the next ~2s tick. Re-read the
|
|
277
|
+
// on-disk marker ONCE here and OR it in so a durable 'aborted' status
|
|
278
|
+
// is never clobbered to 'complete' below (phase-3 final review, FIX 2).
|
|
279
|
+
const wasAborted = abortWatch.wasAborted() || readAbortedMarker(sessionDir);
|
|
280
|
+
markResultAborted(result, wasAborted);
|
|
251
281
|
try {
|
|
252
282
|
const { usage } = await mirror.stop();
|
|
253
283
|
if (usage) { result.usage = usage; }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module progress-fields
|
|
3
|
+
* Derived, agent-facing progress fields shared by the MCP status/list
|
|
4
|
+
* handlers, the `amicus status` CLI, and readProgress(): a sanitized preview
|
|
5
|
+
* of the newest assistant text, and a coarse lifecycle stage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
/** Coarse stages surfaced to agents. */
|
|
11
|
+
const COARSE_STAGES = ['starting', 'generating', 'folding', 'terminal'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collapse whitespace and defang fence/tag characters so the preview can be
|
|
15
|
+
* embedded in a one-line JSON status without opening a code fence or tag
|
|
16
|
+
* (prompt-injection hygiene: the FULL text is only available via amicus_read,
|
|
17
|
+
* which wraps it in the untrusted-output fence).
|
|
18
|
+
* @param {string} text @param {number} [max=120] @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function sanitizePreview(text, max = 120) {
|
|
21
|
+
const collapsed = String(text).replace(/[`<>]/g, '').replace(/\s+/g, ' ').trim();
|
|
22
|
+
return collapsed.length > max ? collapsed.slice(0, max) + '…' : collapsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The newest assistant TEXT content from parsed conversation.jsonl entries,
|
|
27
|
+
* sanitized to ~120 chars. Tool-use/result lines are skipped. Null when no
|
|
28
|
+
* assistant text exists yet.
|
|
29
|
+
* @param {object[]} entries @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
function latestAssistantPreview(entries) {
|
|
32
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
33
|
+
const e = entries[i];
|
|
34
|
+
if (e && e.role === 'assistant' && typeof e.content === 'string' && e.content.trim()) {
|
|
35
|
+
return sanitizePreview(e.content);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Map (metadata.status, progress.stage) to the coarse agent-facing stage.
|
|
43
|
+
* - terminal metadata status -> 'terminal'
|
|
44
|
+
* - progress 'receiving' -> 'generating'
|
|
45
|
+
* - progress 'complete' while metadata still says running -> 'folding'
|
|
46
|
+
* (mirror stopped; summary/conflict finalize in flight)
|
|
47
|
+
* - anything else -> 'starting'
|
|
48
|
+
* @param {string|undefined} metadataStatus @param {string|undefined} progressStage
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function deriveStage(metadataStatus, progressStage) {
|
|
52
|
+
if (metadataStatus && metadataStatus !== 'running' && metadataStatus !== 'unknown') {
|
|
53
|
+
return 'terminal';
|
|
54
|
+
}
|
|
55
|
+
if (progressStage === 'receiving') { return 'generating'; }
|
|
56
|
+
if (progressStage === 'complete') { return 'folding'; }
|
|
57
|
+
return 'starting';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { sanitizePreview, latestAssistantPreview, deriveStage, COARSE_STAGES };
|
package/src/sidecar/progress.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* Sidecar Progress Reader
|
|
3
3
|
*
|
|
4
4
|
* Reads conversation.jsonl and progress.json from a session directory
|
|
5
|
-
* and returns progress info: message count, last activity
|
|
6
|
-
* and
|
|
5
|
+
* and returns progress info: message count, last activity (relative string,
|
|
6
|
+
* raw ms, and absolute ISO), latest action, a sanitized preview of the
|
|
7
|
+
* newest assistant text, and lifecycle stage.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
const fs = require('fs');
|
|
10
11
|
const path = require('path');
|
|
12
|
+
const { latestAssistantPreview } = require('./progress-fields');
|
|
11
13
|
|
|
12
14
|
/** Lifecycle stage labels */
|
|
13
15
|
const STAGE_LABELS = {
|
|
@@ -122,7 +124,9 @@ function writeProgress(sessionDir, stage, extra = {}) {
|
|
|
122
124
|
* Read progress from a session's conversation.jsonl and progress.json files.
|
|
123
125
|
*
|
|
124
126
|
* @param {string} sessionDir - Path to the session directory
|
|
125
|
-
* @returns {{ messages: number, lastActivity: string, latest: string,
|
|
127
|
+
* @returns {{ messages: number, lastActivity: string, latest: string,
|
|
128
|
+
* lastActivityMs: number|null, lastActivityAt: string|null,
|
|
129
|
+
* latestPreview: string|null, stage?: string }}
|
|
126
130
|
*/
|
|
127
131
|
function readProgress(sessionDir) {
|
|
128
132
|
const convPath = path.join(sessionDir, 'conversation.jsonl');
|
|
@@ -159,63 +163,63 @@ function readProgress(sessionDir) {
|
|
|
159
163
|
? computeLastActivity(convStat.mtime)
|
|
160
164
|
: 'never';
|
|
161
165
|
|
|
162
|
-
// Read progress.json
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
// Read + parse progress.json ONCE; stage/latest/lastActivity below and the
|
|
167
|
+
// newestActivity computation all derive from this single parse.
|
|
168
|
+
let progress = null;
|
|
165
169
|
if (fs.existsSync(progressPath)) {
|
|
166
170
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
progress = JSON.parse(fs.readFileSync(progressPath, 'utf-8'));
|
|
172
|
+
} catch {
|
|
173
|
+
// Ignore malformed progress file
|
|
174
|
+
}
|
|
175
|
+
}
|
|
169
176
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
latest = progress.stageLabel;
|
|
173
|
-
}
|
|
177
|
+
// Lifecycle stage info from progress.json
|
|
178
|
+
let stage;
|
|
174
179
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (messages > 0 && progress.latestTool && (latest === 'Working...' || latest === 'Executing tool call...')) {
|
|
178
|
-
latest = `Calling tool: ${progress.latestTool}`;
|
|
179
|
-
}
|
|
180
|
+
if (progress) {
|
|
181
|
+
stage = progress.stage;
|
|
180
182
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
// Use progress stage label when no assistant entries exist yet
|
|
184
|
+
if (messages === 0 && progress.stageLabel) {
|
|
185
|
+
latest = progress.stageLabel;
|
|
186
|
+
}
|
|
185
187
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
lastActivity = computeLastActivity(progressTime);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
} catch {
|
|
194
|
-
// Ignore malformed progress file
|
|
188
|
+
// Use progress.json latestTool for better latest when extractLatest
|
|
189
|
+
// returns a generic fallback (tool_use entries without name)
|
|
190
|
+
if (messages > 0 && progress.latestTool && (latest === 'Working...' || latest === 'Executing tool call...')) {
|
|
191
|
+
latest = `Calling tool: ${progress.latestTool}`;
|
|
195
192
|
}
|
|
196
|
-
}
|
|
197
193
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const progressMs = Date.now() - new Date(progress.updatedAt).getTime();
|
|
209
|
-
if (lastActivityMs === null || progressMs < lastActivityMs) {
|
|
210
|
-
lastActivityMs = progressMs;
|
|
211
|
-
}
|
|
194
|
+
// Use messagesReceived from progress when conversation has no assistant entries
|
|
195
|
+
if (messages === 0 && progress.messagesReceived !== undefined) {
|
|
196
|
+
messages = progress.messagesReceived;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Use progress updatedAt for lastActivity if more recent
|
|
200
|
+
if (progress.updatedAt) {
|
|
201
|
+
const progressTime = new Date(progress.updatedAt);
|
|
202
|
+
if (!convStat || progressTime > convStat.mtime) {
|
|
203
|
+
lastActivity = computeLastActivity(progressTime);
|
|
212
204
|
}
|
|
213
|
-
} catch {
|
|
214
|
-
// Ignore — already handled above
|
|
215
205
|
}
|
|
216
206
|
}
|
|
217
207
|
|
|
218
|
-
|
|
208
|
+
// Newest activity timestamp across BOTH sources (conv mtime, progress.updatedAt):
|
|
209
|
+
// feeds lastActivityMs (stall detection) AND lastActivityAt (absolute ISO for
|
|
210
|
+
// agents) from one value so they can never disagree.
|
|
211
|
+
let newestActivity = convStat ? convStat.mtime : null;
|
|
212
|
+
if (progress && progress.updatedAt) {
|
|
213
|
+
const t = new Date(progress.updatedAt);
|
|
214
|
+
if (!newestActivity || t > newestActivity) { newestActivity = t; }
|
|
215
|
+
}
|
|
216
|
+
const lastActivityMs = newestActivity ? Date.now() - newestActivity.getTime() : null;
|
|
217
|
+
|
|
218
|
+
const result = {
|
|
219
|
+
messages, lastActivity, latest, lastActivityMs,
|
|
220
|
+
lastActivityAt: newestActivity ? newestActivity.toISOString() : null,
|
|
221
|
+
latestPreview: latestAssistantPreview(entries),
|
|
222
|
+
};
|
|
219
223
|
if (stage !== undefined) {
|
|
220
224
|
result.stage = stage;
|
|
221
225
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Marker-first abort coordination (Phase 3 abort overhaul).
|
|
5
|
+
*
|
|
6
|
+
* Contract: the caller writes the metadata marker (status='aborted') FIRST,
|
|
7
|
+
* gives the running process a grace window to honor it (the headless loop and
|
|
8
|
+
* the interactive abort watch both poll metadata every ~2s and tear down
|
|
9
|
+
* gracefully — mirror flush, usage persist, server-side abortSession), and
|
|
10
|
+
* only SIGTERMs a process that is STILL alive after the grace window.
|
|
11
|
+
*
|
|
12
|
+
* Windows: process.kill() is TerminateProcess — no handlers run — but libuv
|
|
13
|
+
* job objects kill non-detached children with the parent, so the fallback
|
|
14
|
+
* kill still reaps the tree. The grace window is what keeps the common path
|
|
15
|
+
* graceful.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { logger } = require('./logger');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Grace window before the fallback SIGTERM. Env-overridable (tests).
|
|
22
|
+
* AMICUS_ABORT_GRACE_MS='0' is treated as UNSET and falls back to the 5000ms
|
|
23
|
+
* default — Number.isFinite(0) && 0 > 0 is false, so an explicit "no grace"
|
|
24
|
+
* is not currently expressible via this env var (adjudicated deviation, 3.3).
|
|
25
|
+
*/
|
|
26
|
+
function abortGraceMs() {
|
|
27
|
+
const n = Number(process.env.AMICUS_ABORT_GRACE_MS);
|
|
28
|
+
return (Number.isFinite(n) && n > 0) ? n : 5000;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {boolean} true when a process with this pid exists. EPERM means
|
|
33
|
+
* the pid exists but the caller lacks permission to signal it — that's
|
|
34
|
+
* ALIVE, not dead; only ESRCH (and other non-EPERM errors) mean dead.
|
|
35
|
+
*/
|
|
36
|
+
function isAlive(pid, kill = process.kill.bind(process)) {
|
|
37
|
+
if (!pid) { return false; }
|
|
38
|
+
try {
|
|
39
|
+
kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return err && err.code === 'EPERM' ? true : false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** SIGTERM a pid, swallowing ESRCH. @returns {boolean} signal was sent */
|
|
47
|
+
function killPidBestEffort(pid, kill = process.kill.bind(process)) {
|
|
48
|
+
if (!pid) { return false; }
|
|
49
|
+
try { kill(pid, 'SIGTERM'); return true; } catch (err) {
|
|
50
|
+
if (err.code !== 'ESRCH') {
|
|
51
|
+
logger.warn('Failed to kill process', { pid, error: err.message });
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wait up to graceMs for the pids to exit on their own (marker-honoring
|
|
59
|
+
* teardown), then SIGTERM any survivor. Early-exits as soon as every target
|
|
60
|
+
* is gone, so a process that honors the marker in ~2s never sees a signal.
|
|
61
|
+
*
|
|
62
|
+
* NOTE: the poll timer is deliberately REF'D. The CLI awaits this call and
|
|
63
|
+
* must stay alive through the grace window; callers that must not block
|
|
64
|
+
* (MCP handler) fire-and-forget the returned promise instead.
|
|
65
|
+
*
|
|
66
|
+
* @param {number|null|Array<number|null>} pids
|
|
67
|
+
* @param {{graceMs?:number, pollMs?:number, deps?:{kill?:Function, sleep?:Function}}} [opts]
|
|
68
|
+
* @returns {Promise<{killed:number[], exited:number[]}>}
|
|
69
|
+
*/
|
|
70
|
+
async function waitThenKill(pids, opts = {}) {
|
|
71
|
+
const graceMs = opts.graceMs !== undefined ? opts.graceMs : abortGraceMs();
|
|
72
|
+
const pollMs = opts.pollMs || 250;
|
|
73
|
+
const deps = opts.deps || {};
|
|
74
|
+
const kill = deps.kill || process.kill.bind(process);
|
|
75
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
76
|
+
|
|
77
|
+
const targets = [...new Set((Array.isArray(pids) ? pids : [pids]).filter(Boolean))];
|
|
78
|
+
const deadline = Date.now() + graceMs;
|
|
79
|
+
let remaining = targets.filter((pid) => isAlive(pid, kill));
|
|
80
|
+
while (remaining.length > 0 && Date.now() < deadline) {
|
|
81
|
+
await sleep(pollMs);
|
|
82
|
+
remaining = remaining.filter((pid) => isAlive(pid, kill));
|
|
83
|
+
}
|
|
84
|
+
const killed = remaining.filter((pid) => killPidBestEffort(pid, kill));
|
|
85
|
+
return {
|
|
86
|
+
killed,
|
|
87
|
+
exited: targets.filter((pid) => !remaining.includes(pid)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { abortGraceMs, isAlive, killPidBestEffort, waitThenKill };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/utils/legacy-mcp-migration.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Legacy 'sidecar' MCP registration cleanup (Phase 4 tool de-bloat).
|
|
6
|
+
*
|
|
7
|
+
* Through v1.7.x scripts/postinstall.js registered the SAME stdio MCP server
|
|
8
|
+
* under two names — 'amicus' and legacy 'sidecar' — in Claude Code
|
|
9
|
+
* (~/.claude.json) and Claude Desktop/Cowork (claude_desktop_config.json).
|
|
10
|
+
* Combined with the in-server sidecar_* tool aliases this quadrupled the
|
|
11
|
+
* client-visible tool surface (13 real tools -> 52).
|
|
12
|
+
*
|
|
13
|
+
* This module removes a legacy 'sidecar' server entry, but ONLY when it is
|
|
14
|
+
* identical-in-effect to the amicus registration: its command must resolve to
|
|
15
|
+
* an amicus MCP invocation per isAmicusMcpConfig() (./mcp-self-identity,
|
|
16
|
+
* Phase 1). A 'sidecar' entry pointing anywhere else is user customization
|
|
17
|
+
* and is NEVER touched.
|
|
18
|
+
*
|
|
19
|
+
* Consumers: scripts/postinstall.js (one-shot migration on install/upgrade)
|
|
20
|
+
* and src/cli-handlers-doctor.js (duplicate check + `doctor --fix`).
|
|
21
|
+
* All functions are synchronous, never throw, and report via return values.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { writeFileAtomic } = require('./atomic-write');
|
|
28
|
+
|
|
29
|
+
/** ~/.claude.json — where BOTH Claude Code registration paths (CLI + file fallback) land. */
|
|
30
|
+
function claudeCodeConfigPath() {
|
|
31
|
+
return path.join(os.homedir(), '.claude.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** claude_desktop_config.json — platform-aware; mirrors postinstall registerClaudeDesktop. */
|
|
35
|
+
function claudeDesktopConfigPath() {
|
|
36
|
+
if (process.platform === 'darwin') {
|
|
37
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
38
|
+
}
|
|
39
|
+
if (process.platform === 'win32') {
|
|
40
|
+
return path.join(process.env.APPDATA || '', 'Claude', 'claude_desktop_config.json');
|
|
41
|
+
}
|
|
42
|
+
return path.join(os.homedir(), '.config', 'claude', 'claude_desktop_config.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultTargets(deps = {}) {
|
|
46
|
+
return [
|
|
47
|
+
{ target: 'Claude Code', configPath: deps.codePath || claudeCodeConfigPath() },
|
|
48
|
+
{ target: 'Claude Desktop', configPath: deps.desktopPath || claudeDesktopConfigPath() },
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Inspect one config file for a legacy 'sidecar' MCP entry.
|
|
54
|
+
* @returns {{status:'absent'|'removable'|'customized'|'unreadable', config?:object}}
|
|
55
|
+
*/
|
|
56
|
+
function inspectLegacySidecarEntry(configPath, deps = {}) {
|
|
57
|
+
const isAmicus = deps.isAmicusMcpConfig
|
|
58
|
+
|| require('./mcp-self-identity').isAmicusMcpConfig;
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
if (!fs.existsSync(configPath)) { return { status: 'absent' }; }
|
|
62
|
+
parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
63
|
+
} catch {
|
|
64
|
+
return { status: 'unreadable' };
|
|
65
|
+
}
|
|
66
|
+
const entry = parsed && parsed.mcpServers ? parsed.mcpServers.sidecar : undefined;
|
|
67
|
+
if (!entry) { return { status: 'absent' }; }
|
|
68
|
+
// 'removable' means VERIFIED duplicate, not just amicus-shaped. Two edges
|
|
69
|
+
// must fall back to 'customized' (left alone) instead:
|
|
70
|
+
// (a) no 'amicus' twin present — this may be the user's ONLY working
|
|
71
|
+
// registration (scripts-skipped / manual pre-rebrand install); deleting
|
|
72
|
+
// it would leave them with nothing.
|
|
73
|
+
// (b) the sidecar entry carries a non-empty `env` (API keys,
|
|
74
|
+
// AMICUS_LEGACY_ALIASES itself) — not identical-in-effect to the bare
|
|
75
|
+
// 'amicus' entry, so removing it would silently lose configuration.
|
|
76
|
+
const hasAmicusTwin = !!(parsed.mcpServers && parsed.mcpServers.amicus);
|
|
77
|
+
const hasNonEmptyEnv = !!(entry.env && typeof entry.env === 'object' && Object.keys(entry.env).length > 0);
|
|
78
|
+
if (!hasAmicusTwin || hasNonEmptyEnv) { return { status: 'customized', config: entry }; }
|
|
79
|
+
return isAmicus(entry)
|
|
80
|
+
? { status: 'removable', config: entry }
|
|
81
|
+
: { status: 'customized', config: entry };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove the legacy 'sidecar' entry from one config file — ONLY when it is an
|
|
86
|
+
* amicus self-invocation. Preserves every other key in the file.
|
|
87
|
+
* @returns {'absent'|'removed'|'customized'|'unreadable'|'write-failed'}
|
|
88
|
+
*/
|
|
89
|
+
function removeLegacySidecarEntry(configPath, deps = {}) {
|
|
90
|
+
const inspected = inspectLegacySidecarEntry(configPath, deps);
|
|
91
|
+
if (inspected.status !== 'removable') { return inspected.status; }
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
94
|
+
delete parsed.mcpServers.sidecar;
|
|
95
|
+
// Atomic temp+rename write — a crash mid-write must never corrupt the
|
|
96
|
+
// user's main Claude Code state file. 0o600 is a no-op on NTFS; kept for
|
|
97
|
+
// parity with addMcpToConfigFile.
|
|
98
|
+
writeFileAtomic(configPath, JSON.stringify(parsed, null, 2), { mode: 0o600 });
|
|
99
|
+
return 'removed';
|
|
100
|
+
} catch {
|
|
101
|
+
return 'write-failed';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Inspect every known registry (doctor check). */
|
|
106
|
+
function inspectAllLegacySidecarEntries(deps = {}) {
|
|
107
|
+
return defaultTargets(deps).map((t) => ({ ...t, ...inspectLegacySidecarEntry(t.configPath, deps) }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Remove identical-in-effect legacy entries everywhere. Idempotent. */
|
|
111
|
+
function migrateLegacySidecar(deps = {}) {
|
|
112
|
+
return defaultTargets(deps).map((t) => ({ ...t, result: removeLegacySidecarEntry(t.configPath, deps) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
claudeCodeConfigPath, claudeDesktopConfigPath,
|
|
117
|
+
inspectLegacySidecarEntry, removeLegacySidecarEntry,
|
|
118
|
+
inspectAllLegacySidecarEntries, migrateLegacySidecar,
|
|
119
|
+
};
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
|
|
13
13
|
// `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
|
|
14
14
|
// interactive Electron flow that must never be force-exited).
|
|
15
|
-
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
|
|
15
|
+
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
|
|
16
16
|
|
|
17
17
|
/** @param {string} command @returns {boolean} */
|
|
18
18
|
function isOneShotCommand(command) {
|
|
@@ -64,6 +64,14 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
64
64
|
* can't loop the way `npm install -g amicus` could when the rollback recurs.
|
|
65
65
|
*/
|
|
66
66
|
doctorFix: 'amicus doctor --fix (self-heal the Electron GUI in place — provisions the binary; no reinstall, so it can\'t loop)',
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Duplicate legacy 'sidecar' MCP registration (Phase 4 de-bloat): pre-1.8
|
|
70
|
+
* postinstalls registered the same server twice. `doctor --fix` removes the
|
|
71
|
+
* twin only when it points at amicus; a customized entry is never touched.
|
|
72
|
+
*/
|
|
73
|
+
removeLegacySidecar:
|
|
74
|
+
"amicus doctor --fix (removes the duplicate legacy 'sidecar' MCP entry — same server registered twice; the 'amicus' entry stays)",
|
|
67
75
|
});
|
|
68
76
|
|
|
69
77
|
module.exports = REMEDIATION_HINTS;
|