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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- 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 };
|