amicus 1.0.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. package/src/utils/validators.js +300 -0
@@ -0,0 +1,625 @@
1
+ /** @module mcp-server — Amicus MCP Server (stdio transport) */
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { spawn } = require('child_process');
5
+ const { getTools, getGuideText } = require('./mcp-tools');
6
+ const { tryResolveModel } = require('./utils/config');
7
+ const os = require('os');
8
+ const { logger } = require('./utils/logger');
9
+ const { safeSessionDir } = require('./utils/validators');
10
+ const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
11
+ const { readProgress } = require('./sidecar/progress');
12
+ const { SharedServerManager } = require('./utils/shared-server');
13
+
14
+ const sharedServer = new SharedServerManager({ logger });
15
+
16
+ /** Resolve the project directory with smart fallback. */
17
+ function getProjectDir(explicitProject) {
18
+ if (explicitProject && fs.existsSync(explicitProject)) { return explicitProject; }
19
+ const cwd = process.cwd();
20
+ if (cwd !== '/' && fs.existsSync(cwd)) { return cwd; }
21
+ if (cwd === '/') { logger.warn('cwd is root (/), falling back to $HOME'); }
22
+ return os.homedir();
23
+ }
24
+
25
+ /** Read session metadata from disk, or null if not found */
26
+ function readMetadata(taskId, project) {
27
+ const sessionDir = safeSessionDir(project, taskId);
28
+ const metaPath = path.join(sessionDir, 'metadata.json');
29
+ if (!fs.existsSync(metaPath)) { return null; }
30
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
31
+ }
32
+
33
+ /** Build an MCP text response */
34
+ function textResult(text, isError) {
35
+ const result = { content: [{ type: 'text', text }] };
36
+ if (isError) { result.isError = true; }
37
+ return result;
38
+ }
39
+
40
+ /**
41
+ * Compute next poll hint for headless sessions.
42
+ * @returns {{ hint: string }}
43
+ */
44
+ function computeNextPoll() {
45
+ return {
46
+ hint: 'Run `sleep 25` in your shell before calling amicus_status again. This enforces the wait and prevents token-wasting rapid polls.',
47
+ wait_command: 'sleep 25',
48
+ };
49
+ }
50
+
51
+ const HEADLESS_START_REMINDER = '<system-reminder>IMPORTANT: Before calling amicus_status, you MUST run `sleep 25` in your shell first. This enforces the polling interval and prevents token waste. Do other useful work while waiting, or run `sleep 25` to block until the next poll window.</system-reminder>';
52
+ const HEADLESS_STATUS_REMINDER = '<system-reminder>IMPORTANT: This session is still running. Before calling amicus_status again, you MUST run `sleep 25` in your shell first. Each premature poll wastes context tokens for zero benefit. Run `sleep 25` now, then check again.</system-reminder>';
53
+
54
+ /** Spawn an Amicus CLI process (fire-and-forget) */
55
+ function spawnSidecarProcess(args, sessionDir) {
56
+ const sidecarBin = path.join(__dirname, '..', 'bin', 'amicus.js');
57
+ let stderrFd = 'ignore';
58
+ if (sessionDir) {
59
+ try {
60
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
61
+ stderrFd = fs.openSync(path.join(sessionDir, 'debug.log'), 'w');
62
+ } catch { /* fall back to ignore */ }
63
+ }
64
+ const child = spawn('node', [sidecarBin, ...args], {
65
+ cwd: getProjectDir(),
66
+ stdio: ['ignore', 'ignore', stderrFd],
67
+ env: { ...process.env, AMICUS_DEBUG_PORT: '9223', LOG_LEVEL: process.env.LOG_LEVEL || 'info' },
68
+ });
69
+ child.unref();
70
+ return child;
71
+ }
72
+
73
+ /** Tool handler implementations */
74
+ const handlers = {
75
+ async amicus_start(input, project) {
76
+ // Validate all inputs before any session creation
77
+ const { validateStartInputs } = require('./utils/input-validators');
78
+ const validation = validateStartInputs(input);
79
+ if (!validation.valid) {
80
+ return {
81
+ isError: true,
82
+ content: [{ type: 'text', text: JSON.stringify(validation.error) }],
83
+ };
84
+ }
85
+ const resolvedModel = validation.resolvedModel;
86
+
87
+ const cwd = project || getProjectDir(input.project);
88
+ const { generateTaskId } = require('./sidecar/start');
89
+ const taskId = generateTaskId();
90
+
91
+ const args = ['start', '--prompt', input.prompt, '--task-id', taskId, '--client', 'cowork'];
92
+ if (resolvedModel) { args.push('--model', resolvedModel); }
93
+ const agent = (input.noUi && (!input.agent || input.agent.toLowerCase() === 'chat'))
94
+ ? 'build' : input.agent;
95
+ if (agent) { args.push('--agent', agent); }
96
+ if (input.noUi) { args.push('--no-ui'); }
97
+ if (input.thinking) { args.push('--thinking', input.thinking); }
98
+ if (input.timeout) { args.push('--timeout', String(input.timeout)); }
99
+ if (input.contextTurns) { args.push('--context-turns', String(input.contextTurns)); }
100
+ if (input.contextSince) { args.push('--context-since', input.contextSince); }
101
+ if (input.contextMaxTokens) { args.push('--context-max-tokens', String(input.contextMaxTokens)); }
102
+ if (input.summaryLength) { args.push('--summary-length', input.summaryLength); }
103
+ if (input.includeContext === false) { args.push('--no-context'); }
104
+ if (input.coworkProcess) { args.push('--cowork-process', input.coworkProcess); }
105
+ if (input.parentSession) { args.push('--session-id', input.parentSession); }
106
+ if (input.windowPosition) { args.push('--position', input.windowPosition); }
107
+ args.push('--cwd', cwd);
108
+
109
+ // New session → canonical amicus dir (writes).
110
+ const sessionDir = getSessionDir(cwd, taskId);
111
+
112
+ if (sharedServer.enabled && input.noUi) {
113
+ // Shared server path: headless only, delegates to runHeadless()
114
+ let sessionId;
115
+ try {
116
+ const { server, client } = await sharedServer.ensureServer();
117
+ const { createSession } = require('./opencode-client');
118
+ const { buildContext } = require('./sidecar/context-builder');
119
+ const { buildPrompts } = require('./prompt-builder');
120
+ const { runHeadless } = require('./headless');
121
+ const { finalizeSession } = require('./sidecar/session-utils');
122
+ // resolvedModel is already available from validateStartInputs() above
123
+
124
+ sessionId = await createSession(client);
125
+
126
+ // Write initial metadata (MCP handler owns this, runHeadless skips it)
127
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
128
+ const metaPath = path.join(sessionDir, 'metadata.json');
129
+ const serverPort = server.url ? new URL(server.url).port : null;
130
+ fs.writeFileSync(metaPath, JSON.stringify({
131
+ taskId, status: 'running',
132
+ pid: null, // Shared server path: don't store MCP server PID (abort would kill all sessions)
133
+ opencodeSessionId: sessionId,
134
+ opencodePort: serverPort,
135
+ goPid: server.goPid || null,
136
+ createdAt: new Date().toISOString(),
137
+ headless: true, model: resolvedModel,
138
+ }, null, 2), { mode: 0o600 });
139
+
140
+ // Build context from parent conversation (unless --no-context)
141
+ let context = null;
142
+ if (input.includeContext !== false) {
143
+ try {
144
+ context = buildContext(cwd, input.parentSession, {
145
+ contextTurns: input.contextTurns,
146
+ contextSince: input.contextSince,
147
+ contextMaxTokens: input.contextMaxTokens,
148
+ coworkProcess: input.coworkProcess,
149
+ });
150
+ } catch (ctxErr) {
151
+ logger.warn('Failed to build context, proceeding without', { error: ctxErr.message });
152
+ }
153
+ }
154
+
155
+ // Build prompts (same as CLI path in start.js)
156
+ const { system: systemPrompt, userMessage } = buildPrompts(
157
+ input.prompt, context, cwd, true, agent, input.summaryLength
158
+ );
159
+
160
+ // Register session with idle eviction
161
+ sharedServer.addSession(sessionId, (_evictedId) => {
162
+ try {
163
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
164
+ meta.status = 'idle-timeout';
165
+ meta.completedAt = new Date().toISOString();
166
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
167
+ } catch (err) {
168
+ logger.warn('Failed to update evicted session metadata', { error: err.message });
169
+ }
170
+ });
171
+ const watchdog = sharedServer.getSessionWatchdog(sessionId);
172
+
173
+ const timeoutMs = (input.timeout || 15) * 60 * 1000;
174
+
175
+ // Fire-and-forget: runHeadless with shared server's client
176
+ runHeadless(resolvedModel, systemPrompt, userMessage, taskId, cwd,
177
+ timeoutMs, agent, {
178
+ client, server, watchdog, sessionId,
179
+ mcp: undefined, // shared server already has MCP config
180
+ }
181
+ ).then((result) => {
182
+ // Session complete - finalize and remove from tracking
183
+ try {
184
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
185
+ finalizeSession(sessionDir, result.summary || '', cwd, meta);
186
+ } catch (finErr) {
187
+ logger.warn('Failed to finalize session', { error: finErr.message });
188
+ }
189
+ sharedServer.removeSession(sessionId);
190
+ }).catch((err) => {
191
+ logger.error('Shared server session failed', { taskId, error: err.message });
192
+ sharedServer.removeSession(sessionId);
193
+ try {
194
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
195
+ meta.status = 'error';
196
+ meta.reason = err.message;
197
+ meta.completedAt = new Date().toISOString();
198
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
199
+ } catch (writeErr) {
200
+ logger.warn('Failed to write error metadata', { error: writeErr.message });
201
+ }
202
+ });
203
+
204
+ // Return immediately
205
+ const body = JSON.stringify({
206
+ taskId, status: 'running', mode: 'headless',
207
+ message: 'Amicus started in headless mode. Use amicus_status to check progress.',
208
+ });
209
+ return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
210
+ } catch (err) {
211
+ logger.warn('Shared server path failed, falling back to spawn', { error: err.message });
212
+ // Clean up partial shared server state before falling through
213
+ if (sessionId) {
214
+ sharedServer.removeSession(sessionId);
215
+ }
216
+ // Fall through to spawn path below
217
+ }
218
+ }
219
+
220
+ // Feature flag disabled (or shared server failed): fall back to per-process spawn
221
+ let child;
222
+ try { child = spawnSidecarProcess(args, sessionDir); } catch (err) {
223
+ return textResult(`Failed to start Amicus: ${err.message}`, true);
224
+ }
225
+
226
+ if (child && child.pid) {
227
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
228
+ const metaPath = path.join(sessionDir, 'metadata.json');
229
+ if (!fs.existsSync(metaPath)) {
230
+ fs.writeFileSync(metaPath, JSON.stringify({
231
+ taskId, status: 'running', pid: child.pid, createdAt: new Date().toISOString(),
232
+ headless: !!input.noUi,
233
+ }, null, 2), { mode: 0o600 });
234
+ }
235
+ }
236
+
237
+ const isHeadless = !!input.noUi;
238
+ const mode = isHeadless ? 'headless' : 'interactive';
239
+ const message = isHeadless
240
+ ? 'Amicus started in headless mode. Use amicus_status to check progress.'
241
+ : 'Amicus opened in interactive mode. Do NOT poll for status. ' +
242
+ "Tell the user: 'Let me know when you're done with the session and have clicked Fold.' " +
243
+ 'Then wait for the user to tell you. Use amicus_read to get results once they confirm.';
244
+
245
+ const body = JSON.stringify({ taskId, status: 'running', mode, message });
246
+ if (isHeadless) {
247
+ return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
248
+ }
249
+ return textResult(body);
250
+ },
251
+
252
+ async amicus_status(input, project) {
253
+ const cwd = project || getProjectDir(input.project);
254
+ const sessionDir = safeSessionDir(cwd, input.taskId);
255
+ const metadata = readMetadata(input.taskId, cwd);
256
+ if (!metadata) { return textResult(`Session ${input.taskId} not found.`, true); }
257
+
258
+ if (metadata.type === 'wave') {
259
+ const legs = (metadata.legs || []).map((legId) => {
260
+ const m = readMetadata(legId, cwd);
261
+ return { taskId: legId, model: (m && m.model) || null, status: (m && m.status) || 'unknown' };
262
+ });
263
+ const { TERMINAL_STATUSES } = require('./utils/result-schema');
264
+ const done = legs.filter(l => TERMINAL_STATUSES.includes(l.status)).length;
265
+
266
+ // Crash-detection for hard-killed fanout processes: the wave branch
267
+ // returns early, so the single-session pid probe below never runs here.
268
+ if (metadata.status === 'running' && metadata.pid) {
269
+ try { process.kill(metadata.pid, 0); } catch {
270
+ const crashedAt = new Date().toISOString();
271
+ Object.assign(metadata, {
272
+ status: 'crashed', crashedAt,
273
+ reason: 'Fan-out process exited unexpectedly',
274
+ });
275
+ fs.writeFileSync(path.join(sessionDir, 'metadata.json'),
276
+ JSON.stringify(metadata, null, 2), { mode: 0o600 });
277
+ // Cascade to legs whose pollers died with the parent
278
+ for (const leg of legs) {
279
+ if (leg.status === 'running') {
280
+ const legMeta = readMetadata(leg.taskId, cwd);
281
+ if (legMeta) {
282
+ Object.assign(legMeta, {
283
+ status: 'crashed', crashedAt,
284
+ reason: 'Parent fan-out process killed',
285
+ });
286
+ fs.writeFileSync(
287
+ path.join(getSessionDir(cwd, leg.taskId), 'metadata.json'),
288
+ JSON.stringify(legMeta, null, 2), { mode: 0o600 });
289
+ leg.status = 'crashed';
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ const ms = Date.now() - new Date(metadata.createdAt).getTime();
297
+ const response = {
298
+ taskId: metadata.taskId, type: 'wave', status: metadata.status,
299
+ legsComplete: done, legsTotal: legs.length, legs,
300
+ elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
301
+ };
302
+ if (metadata.status === 'crashed' || metadata.status === 'error') {
303
+ response.reason = metadata.reason || 'Unknown error';
304
+ }
305
+ const responseText = JSON.stringify(response);
306
+ if (metadata.status === 'running') {
307
+ return { content: [{ type: 'text', text: responseText }, { type: 'text', text: HEADLESS_STATUS_REMINDER }] };
308
+ }
309
+ return textResult(responseText);
310
+ }
311
+
312
+ if (metadata.status === 'running' && metadata.pid) {
313
+ try { process.kill(metadata.pid, 0); } catch {
314
+ Object.assign(metadata, {
315
+ status: 'crashed', crashedAt: new Date().toISOString(),
316
+ reason: 'Process exited unexpectedly',
317
+ });
318
+ fs.writeFileSync(path.join(sessionDir, 'metadata.json'),
319
+ JSON.stringify(metadata, null, 2));
320
+ }
321
+ }
322
+
323
+ const ms = Date.now() - new Date(metadata.createdAt).getTime();
324
+ const response = {
325
+ taskId: metadata.taskId, status: metadata.status,
326
+ elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
327
+ };
328
+ if (metadata.model) { response.model = metadata.model; }
329
+
330
+ if (metadata.status === 'running') {
331
+ const progress = readProgress(sessionDir);
332
+ Object.assign(response, progress);
333
+
334
+ // Stall detection: flag when no activity for 2+ minutes
335
+ const STALL_THRESHOLD_MS = 120000;
336
+ if (metadata.headless && progress.lastActivityMs !== null && progress.lastActivityMs > STALL_THRESHOLD_MS) {
337
+ response.stalled = true;
338
+ response.stalledForSeconds = Math.floor(progress.lastActivityMs / 1000);
339
+ response.recovery = `This session appears stalled (no activity for ${response.stalledForSeconds}s). ` +
340
+ `To recover: 1) call amicus_abort with taskId "${input.taskId}" ` +
341
+ `2) call amicus_resume with taskId "${input.taskId}" and noUi: true to pick up where it left off.`;
342
+ }
343
+
344
+ if (metadata.headless) {
345
+ response.next_poll = computeNextPoll();
346
+ }
347
+ }
348
+ if (metadata.status === 'crashed' || metadata.status === 'error') {
349
+ response.reason = metadata.reason || 'Unknown error';
350
+ }
351
+ const responseText = JSON.stringify(response);
352
+ if (metadata.status === 'running' && metadata.headless) {
353
+ return { content: [{ type: 'text', text: responseText }, { type: 'text', text: HEADLESS_STATUS_REMINDER }] };
354
+ }
355
+ return textResult(responseText);
356
+ },
357
+
358
+ async amicus_read(input, project) {
359
+ const cwd = project || getProjectDir(input.project);
360
+ const sessionDir = safeSessionDir(cwd, input.taskId);
361
+ if (!fs.existsSync(sessionDir)) {
362
+ return textResult(`Session ${input.taskId} not found.`, true);
363
+ }
364
+
365
+ const readMeta = (() => {
366
+ try { return JSON.parse(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8')); }
367
+ catch { return {}; }
368
+ })();
369
+ if (readMeta.type === 'wave' && (input.mode || 'summary') === 'summary') {
370
+ const wavePath = path.join(sessionDir, 'wave.json');
371
+ if (fs.existsSync(wavePath)) {
372
+ return textResult(fs.readFileSync(wavePath, 'utf-8'));
373
+ }
374
+ const legsTotal = (readMeta.legs || []).length;
375
+ const stillRunning = !readMeta.status || readMeta.status === 'running';
376
+ const msg = stillRunning
377
+ ? `Wave ${input.taskId} is still running (${legsTotal} legs). Poll amicus_status.`
378
+ : `Wave ${input.taskId} ended with status '${readMeta.status}' before writing wave.json ` +
379
+ '(fan-out may have been killed). Read individual legs by taskId, or use mode \'metadata\'.';
380
+ return textResult(msg);
381
+ }
382
+
383
+ const mode = input.mode || 'summary';
384
+ if (mode === 'metadata') {
385
+ return textResult(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8'));
386
+ }
387
+ if (mode === 'conversation') {
388
+ const convPath = path.join(sessionDir, 'conversation.jsonl');
389
+ if (!fs.existsSync(convPath)) { return textResult('No conversation recorded.'); }
390
+ return textResult(fs.readFileSync(convPath, 'utf-8'));
391
+ }
392
+ // Default: summary
393
+ const summaryPath = path.join(sessionDir, 'summary.md');
394
+ if (!fs.existsSync(summaryPath)) {
395
+ return textResult('No summary available (session may still be running or was not folded).');
396
+ }
397
+ const metaForRead = (() => {
398
+ try { return JSON.parse(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8')); }
399
+ catch { return {}; }
400
+ })();
401
+ const summaryText = fs.readFileSync(summaryPath, 'utf-8');
402
+ const header = metaForRead.model ? `**Model:** ${metaForRead.model}\n\n` : '';
403
+ return textResult(header + summaryText);
404
+ },
405
+
406
+ async amicus_list(input, project) {
407
+ const cwd = project || getProjectDir(input.project);
408
+ // Scan BOTH roots: canonical amicus first, then legacy sidecar (shim).
409
+ const roots = [SESSIONS_DIR, LEGACY_SESSIONS_DIR]
410
+ .map(d => path.join(cwd, '.claude', d))
411
+ .filter(fs.existsSync);
412
+ if (roots.length === 0) { return textResult('No amicus sessions found.'); }
413
+
414
+ // Dedup by task id — amicus (first root) wins over legacy.
415
+ const byId = new Map();
416
+ for (const root of roots) {
417
+ for (const d of fs.readdirSync(root)) {
418
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(d)) { continue; }
419
+ if (byId.has(d)) { continue; }
420
+ const metaPath = path.join(root, d, 'metadata.json');
421
+ if (!fs.existsSync(metaPath)) { continue; }
422
+ try {
423
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
424
+ byId.set(d, {
425
+ id: d, model: meta.model, status: meta.status, agent: meta.agent,
426
+ briefing: (String(meta.briefing || '')).slice(0, 80),
427
+ createdAt: meta.createdAt,
428
+ });
429
+ } catch {
430
+ // Skip unreadable metadata
431
+ }
432
+ }
433
+ }
434
+
435
+ let sessions = Array.from(byId.values())
436
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
437
+
438
+ if (input.status && input.status !== 'all') {
439
+ sessions = sessions.filter(s => s.status === input.status);
440
+ }
441
+ if (sessions.length === 0) { return textResult('No amicus sessions found.'); }
442
+
443
+ return textResult(JSON.stringify(sessions, null, 2));
444
+ },
445
+
446
+ async amicus_resume(input, project) {
447
+ const cwd = project || getProjectDir(input.project);
448
+ const sessionDir = safeSessionDir(cwd, input.taskId);
449
+ const args = ['resume', input.taskId, '--client', 'cowork', '--cwd', cwd];
450
+ if (input.noUi) { args.push('--no-ui', '--agent', 'build'); }
451
+ if (input.timeout) { args.push('--timeout', String(input.timeout)); }
452
+ try { spawnSidecarProcess(args, sessionDir); } catch (err) {
453
+ return textResult(`Failed to resume: ${err.message}`, true);
454
+ }
455
+ return textResult(JSON.stringify({
456
+ taskId: input.taskId, status: 'running',
457
+ message: 'Session resumed. Use amicus_status to check progress.',
458
+ }));
459
+ },
460
+
461
+ async amicus_continue(input, project) {
462
+ if (input.model) {
463
+ const modelCheck = tryResolveModel(input.model);
464
+ if (modelCheck.error) {
465
+ return textResult(modelCheck.error, true);
466
+ }
467
+ }
468
+
469
+ const cwd = project || getProjectDir(input.project);
470
+ const { generateTaskId } = require('./sidecar/start');
471
+ const newTaskId = generateTaskId();
472
+ // New continuation session → canonical amicus dir (writes).
473
+ const sessionDir = getSessionDir(cwd, newTaskId);
474
+
475
+ const args = ['continue', input.taskId, '--prompt', input.prompt,
476
+ '--task-id', newTaskId, '--client', 'cowork', '--cwd', cwd];
477
+ if (input.model) { args.push('--model', input.model); }
478
+ if (input.noUi) { args.push('--no-ui', '--agent', 'build'); }
479
+ if (input.timeout) { args.push('--timeout', String(input.timeout)); }
480
+ if (input.contextTurns) { args.push('--context-turns', String(input.contextTurns)); }
481
+ if (input.contextMaxTokens) { args.push('--context-max-tokens', String(input.contextMaxTokens)); }
482
+ try { spawnSidecarProcess(args, sessionDir); } catch (err) {
483
+ return textResult(`Failed to continue: ${err.message}`, true);
484
+ }
485
+ return textResult(JSON.stringify({
486
+ taskId: newTaskId, status: 'running',
487
+ message: 'Continuation started. Use amicus_status to check progress.',
488
+ }));
489
+ },
490
+
491
+ async amicus_abort(input, project) {
492
+ const cwd = project || getProjectDir(input.project);
493
+ const metadata = readMetadata(input.taskId, cwd);
494
+ if (!metadata) { return textResult(`Session ${input.taskId} not found.`, true); }
495
+ if (metadata.status !== 'running') {
496
+ return textResult(`Session ${input.taskId} is not running (status: ${metadata.status}).`);
497
+ }
498
+
499
+ if (metadata.pid) {
500
+ try { process.kill(metadata.pid, 'SIGTERM'); } catch (err) {
501
+ if (err.code !== 'ESRCH') {
502
+ logger.warn('Failed to kill Amicus process', { pid: metadata.pid, error: err.message });
503
+ }
504
+ }
505
+ }
506
+ const sessionDir = safeSessionDir(cwd, input.taskId);
507
+ const metaPath = path.join(sessionDir, 'metadata.json');
508
+ metadata.status = 'aborted';
509
+ metadata.abortedAt = new Date().toISOString();
510
+ fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2));
511
+
512
+ return textResult(JSON.stringify({
513
+ taskId: input.taskId, status: 'aborted',
514
+ message: 'Session abort requested. The Amicus process will terminate shortly.',
515
+ }));
516
+ },
517
+
518
+ async amicus_fanout(input, project) {
519
+ const cwd = project || getProjectDir(input.project);
520
+ const { generateTaskId } = require('./sidecar/start');
521
+ const { deriveLegIds } = require('./sidecar/fanout');
522
+ const waveId = generateTaskId();
523
+ const legIds = deriveLegIds(waveId, input.models.length);
524
+ const waveDir = getSessionDir(cwd, waveId);
525
+
526
+ let briefingPath;
527
+ try {
528
+ fs.mkdirSync(waveDir, { recursive: true, mode: 0o700 });
529
+ briefingPath = path.join(waveDir, 'briefing.md');
530
+ // The prompt goes via file: the spawned command line must NOT carry it,
531
+ // or it re-hits the ~32KB Windows argument cap (F4 spec §4.2).
532
+ fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
533
+ fs.writeFileSync(path.join(waveDir, 'metadata.json'), JSON.stringify({
534
+ taskId: waveId, type: 'wave', status: 'running', legs: legIds,
535
+ models: input.models, headless: true, createdAt: new Date().toISOString(),
536
+ }, null, 2), { mode: 0o600 });
537
+ } catch (err) {
538
+ return textResult(`Failed to prepare fan-out wave: ${err.message}`, true);
539
+ }
540
+
541
+ const args = [
542
+ 'fanout', '--models', input.models.join(','),
543
+ '--prompt-file', briefingPath, '--wave-id', waveId,
544
+ '--json', '--client', 'cowork', '--cwd', cwd,
545
+ ];
546
+ const agent = input.agent || 'Build';
547
+ args.push('--agent', agent);
548
+ if (input.thinking) { args.push('--thinking', input.thinking); }
549
+ if (input.timeout) { args.push('--timeout', String(input.timeout)); }
550
+ if (input.summaryLength) { args.push('--summary-length', input.summaryLength); }
551
+ if (input.includeContext === false) { args.push('--no-context'); }
552
+
553
+ try { spawnSidecarProcess(args, waveDir); } catch (err) {
554
+ // Best-effort: never leave a pid-less wave record claiming 'running'
555
+ // forever (crash detection only probes records WITH a pid).
556
+ try {
557
+ const m = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8'));
558
+ Object.assign(m, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
559
+ fs.writeFileSync(path.join(waveDir, 'metadata.json'), JSON.stringify(m, null, 2), { mode: 0o600 });
560
+ } catch { /* best-effort */ }
561
+ return textResult(`Failed to start fan-out: ${err.message}`, true);
562
+ }
563
+
564
+ const body = JSON.stringify({
565
+ waveId, taskIds: legIds, status: 'running', mode: 'headless',
566
+ message: 'Fan-out started. Poll amicus_status with the waveId; amicus_read the waveId when complete.',
567
+ });
568
+ return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
569
+ },
570
+
571
+ async amicus_setup() {
572
+ try { spawnSidecarProcess(['setup']); } catch (err) {
573
+ return textResult(`Failed to launch setup: ${err.message}`, true);
574
+ }
575
+ return textResult('Setup wizard launched. The Electron window should appear on your desktop.');
576
+ },
577
+ async amicus_guide() { return textResult(getGuideText()); },
578
+ };
579
+
580
+ // DEPRECATED(amicus-shim): also register each tool under its legacy sidecar_*
581
+ // name so existing agent scripts keep working. Remove in a future revision.
582
+ const LEGACY_TOOL_ALIASES = {
583
+ amicus_start: 'sidecar_start', amicus_status: 'sidecar_status',
584
+ amicus_read: 'sidecar_read', amicus_list: 'sidecar_list',
585
+ amicus_resume: 'sidecar_resume', amicus_continue: 'sidecar_continue',
586
+ amicus_setup: 'sidecar_setup', amicus_abort: 'sidecar_abort',
587
+ amicus_fanout: 'sidecar_fanout',
588
+ amicus_guide: 'sidecar_guide',
589
+ };
590
+
591
+ /** Start the MCP server on stdio transport */
592
+ async function startMcpServer() {
593
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
594
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
595
+ const server = new McpServer({ name: 'amicus', version: require('../package.json').version });
596
+
597
+ for (const tool of getTools()) {
598
+ const register = (name) => server.registerTool(
599
+ name,
600
+ { description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations },
601
+ async (input) => {
602
+ try { return await handlers[tool.name](input, getProjectDir(input.project)); }
603
+ catch (err) {
604
+ logger.error(`MCP tool error: ${name}`, { error: err.message });
605
+ return textResult(`Error: ${err.message}`, true);
606
+ }
607
+ }
608
+ );
609
+ register(tool.name);
610
+ if (LEGACY_TOOL_ALIASES[tool.name]) { register(LEGACY_TOOL_ALIASES[tool.name]); }
611
+ }
612
+ process.on('SIGTERM', () => {
613
+ sharedServer.shutdown();
614
+ process.exit(0);
615
+ });
616
+ process.on('SIGINT', () => {
617
+ sharedServer.shutdown();
618
+ process.exit(0);
619
+ });
620
+ const transport = new StdioServerTransport();
621
+ await server.connect(transport);
622
+ process.stderr.write('[amicus] MCP server running on stdio\n');
623
+ }
624
+
625
+ module.exports = { handlers, startMcpServer, getProjectDir, LEGACY_TOOL_ALIASES };