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.
@@ -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 };
@@ -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 time, latest action,
6
- * and lifecycle stage.
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, stage?: 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 for lifecycle stage info
163
- let stage;
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
- const progress = JSON.parse(fs.readFileSync(progressPath, 'utf-8'));
168
- stage = progress.stage;
171
+ progress = JSON.parse(fs.readFileSync(progressPath, 'utf-8'));
172
+ } catch {
173
+ // Ignore malformed progress file
174
+ }
175
+ }
169
176
 
170
- // Use progress stage label when no assistant entries exist yet
171
- if (messages === 0 && progress.stageLabel) {
172
- latest = progress.stageLabel;
173
- }
177
+ // Lifecycle stage info from progress.json
178
+ let stage;
174
179
 
175
- // Use progress.json latestTool for better latest when extractLatest
176
- // returns a generic fallback (tool_use entries without name)
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
- // Use messagesReceived from progress when conversation has no assistant entries
182
- if (messages === 0 && progress.messagesReceived !== undefined) {
183
- messages = progress.messagesReceived;
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
- // Use progress updatedAt for lastActivity if more recent
187
- if (progress.updatedAt) {
188
- const progressTime = new Date(progress.updatedAt);
189
- if (!convStat || progressTime > convStat.mtime) {
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
- // Compute raw lastActivityMs for stall detection
199
- let lastActivityMs = null;
200
- if (convStat) {
201
- lastActivityMs = Date.now() - convStat.mtime.getTime();
202
- }
203
- // Use progress.json updatedAt if more recent
204
- if (fs.existsSync(progressPath)) {
205
- try {
206
- const progress = JSON.parse(fs.readFileSync(progressPath, 'utf-8'));
207
- if (progress.updatedAt) {
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
- const result = { messages, lastActivity, latest, lastActivityMs };
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
+ };
@@ -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;