amicus 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +59 -0
- package/electron/load-failsafe.js +11 -4
- package/electron/main.js +22 -13
- package/electron/opencode-theme.js +130 -0
- package/electron/preload.js +1 -1
- package/electron/session-route.js +28 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +59 -35
- package/src/cli-handlers-doctor.js +90 -14
- package/src/cli-handlers-run.js +4 -0
- package/src/cli.js +53 -0
- package/src/headless.js +21 -7
- package/src/mcp-server.js +167 -24
- package/src/mcp-tools.js +17 -1
- package/src/opencode-client.js +44 -10
- package/src/session-manager.js +5 -0
- package/src/sidecar/electron-cache.js +42 -0
- package/src/sidecar/electron-ensure.js +92 -0
- package/src/sidecar/electron-install.js +244 -0
- package/src/sidecar/fanout.js +3 -0
- package/src/sidecar/interactive.js +43 -17
- package/src/sidecar/setup-window.js +12 -7
- package/src/sidecar/setup.js +2 -0
- package/src/utils/project-path.js +61 -0
- package/src/utils/project-root-sanity.js +66 -0
- package/src/utils/remediation-hints.js +51 -0
- package/src/utils/result-schema.js +16 -3
- package/src/utils/session-index.js +98 -0
- package/src/utils/session-path.js +68 -0
- package/src/utils/validators.js +3 -27
- package/src/utils/version-info.js +49 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// src/cli-handlers-doctor.js
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const HINTS = require('./utils/remediation-hints');
|
|
5
|
+
|
|
4
6
|
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
|
|
5
7
|
|
|
8
|
+
/** #56: keep `doctor --fix`'s electron self-heal from ever hanging on a slow disk/network. */
|
|
9
|
+
const FIX_TIMEOUT_MS = 90 * 1000;
|
|
10
|
+
|
|
6
11
|
/** Default real helpers; tests override via deps. */
|
|
7
12
|
function realDeps() {
|
|
8
13
|
const fs = require('fs');
|
|
@@ -11,6 +16,13 @@ function realDeps() {
|
|
|
11
16
|
return {
|
|
12
17
|
nodeVersion: process.version,
|
|
13
18
|
readApiKeys: () => require('./utils/api-key-store').readApiKeys(),
|
|
19
|
+
readApiKeyValues: () => require('./utils/api-key-store').readApiKeyValues(),
|
|
20
|
+
checkOpenRouterCredit: (key) => require('./utils/api-key-validation').checkOpenRouterCredit(key),
|
|
21
|
+
getCwd: () => process.cwd(),
|
|
22
|
+
readProjectMarkers: (dir) => {
|
|
23
|
+
const exists = (name) => { try { return fs.existsSync(path.join(dir, name)); } catch (_e) { return false; } };
|
|
24
|
+
return { hasGit: exists('.git'), hasPackageJson: exists('package.json'), hasClaude: exists('.claude') };
|
|
25
|
+
},
|
|
14
26
|
getConfigDir: () => require('./utils/config').getConfigDir(),
|
|
15
27
|
resolveModel: () => require('./utils/config').resolveModel(),
|
|
16
28
|
readCache: () => require('./utils/model-catalog').readCache(),
|
|
@@ -27,6 +39,10 @@ function realDeps() {
|
|
|
27
39
|
return candidates.some(p => fs.existsSync(p));
|
|
28
40
|
},
|
|
29
41
|
getElectronPath: () => require('./sidecar/interactive').getElectronPath(),
|
|
42
|
+
// #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
|
|
43
|
+
// stays separate; repair only runs when fix is requested.
|
|
44
|
+
repairElectron: (opts) => require('./sidecar/electron-install').repairElectron(opts),
|
|
45
|
+
fix: false,
|
|
30
46
|
discoverClaudeCodeMcps: () => require('./utils/mcp-discovery').discoverClaudeCodeMcps(),
|
|
31
47
|
discoverCoworkMcps: () => require('./utils/mcp-discovery').discoverCoworkMcps(),
|
|
32
48
|
skillInstalled: () => {
|
|
@@ -43,12 +59,19 @@ function guard(id, name, fn) {
|
|
|
43
59
|
catch (e) { return { id, name, status: 'error', message: e.message, hint: null }; }
|
|
44
60
|
}
|
|
45
61
|
|
|
62
|
+
/** Async variant of guard; a thrown/rejected fn becomes an error line. */
|
|
63
|
+
async function guardAsync(id, name, fn) {
|
|
64
|
+
try { return await fn(); }
|
|
65
|
+
catch (e) { return { id, name, status: 'error', message: e.message, hint: null }; }
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
/**
|
|
47
|
-
* Compose the health checks. Never throws
|
|
69
|
+
* Compose the health checks. Never throws (async; awaits non-blocking
|
|
70
|
+
* network checks such as the OpenRouter credit probe).
|
|
48
71
|
* @param {object} [depsOverride]
|
|
49
|
-
* @returns {Array<{id,name,status,message,hint}
|
|
72
|
+
* @returns {Promise<Array<{id,name,status,message,hint}>>}
|
|
50
73
|
*/
|
|
51
|
-
function runDoctorChecks(depsOverride = {}) {
|
|
74
|
+
async function runDoctorChecks(depsOverride = {}) {
|
|
52
75
|
const d = { ...realDeps(), ...depsOverride };
|
|
53
76
|
const checks = [];
|
|
54
77
|
|
|
@@ -105,19 +128,43 @@ function runDoctorChecks(depsOverride = {}) {
|
|
|
105
128
|
checks.push(guard('opencode-bin', 'OpenCode binary', () => (
|
|
106
129
|
d.hasOpencodeBinary()
|
|
107
130
|
? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
|
|
108
|
-
: { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint:
|
|
131
|
+
: { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngine }
|
|
109
132
|
)));
|
|
110
133
|
|
|
111
|
-
checks.push(
|
|
112
|
-
d.getElectronPath()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
|
|
135
|
+
if (d.getElectronPath()) {
|
|
136
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
|
|
137
|
+
}
|
|
138
|
+
// Broken (missing / quarantined). With --fix, self-heal in place (#56):
|
|
139
|
+
// repairElectron provisions the binary; {deferred} (no cache, no network)
|
|
140
|
+
// maps to WARN — a deferred download is not a failure. Without --fix, just
|
|
141
|
+
// point the user at `amicus doctor --fix`.
|
|
142
|
+
if (d.fix) {
|
|
143
|
+
let res;
|
|
144
|
+
try {
|
|
145
|
+
res = await d.repairElectron({ timeoutMs: FIX_TIMEOUT_MS });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `repair failed: ${e.message} — headless still works`, hint: HINTS.doctorFix };
|
|
148
|
+
}
|
|
149
|
+
res = res || {};
|
|
150
|
+
if (res.repaired || res.usable) {
|
|
151
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed (self-healed)', hint: null };
|
|
152
|
+
}
|
|
153
|
+
const why = res.reason ? ` — ${res.reason}` : '';
|
|
154
|
+
const detail = res.deferred
|
|
155
|
+
? `deferred${why}`
|
|
156
|
+
: res.contended
|
|
157
|
+
? `repair already in progress${why}`
|
|
158
|
+
: `not provisioned${why}`;
|
|
159
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `${detail} — headless still works`, hint: HINTS.doctorFix };
|
|
160
|
+
}
|
|
161
|
+
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: HINTS.doctorFix };
|
|
162
|
+
}));
|
|
116
163
|
|
|
117
164
|
checks.push(guard('skills', 'Skills installed', () => (
|
|
118
165
|
d.skillInstalled()
|
|
119
166
|
? { id: 'skills', name: 'Skills installed', status: 'ok', message: '~/.claude/skills/{sidecar,second-opinion}', hint: null }
|
|
120
|
-
: { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint:
|
|
167
|
+
: { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint: `${HINTS.reinstall} (re-runs the skill install)` }
|
|
121
168
|
)));
|
|
122
169
|
|
|
123
170
|
checks.push(guard('mcp', 'MCP registration', () => {
|
|
@@ -127,12 +174,37 @@ function runDoctorChecks(depsOverride = {}) {
|
|
|
127
174
|
const inCowork = !!(cowork && cowork.amicus);
|
|
128
175
|
// Primary signal: Claude Code MCP registration. Cowork/Desktop is reported as bonus only.
|
|
129
176
|
if (!inCode) {
|
|
130
|
-
return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint:
|
|
177
|
+
return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint: `${HINTS.reinstall} (or install the amicus plugin)` };
|
|
131
178
|
}
|
|
132
179
|
const extra = inCowork ? ', Cowork/Desktop' : '';
|
|
133
180
|
return { id: 'mcp', name: 'MCP registration', status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
|
|
134
181
|
}));
|
|
135
182
|
|
|
183
|
+
// #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
|
|
184
|
+
checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
|
|
185
|
+
const values = d.readApiKeyValues() || {};
|
|
186
|
+
const key = values.openrouter;
|
|
187
|
+
if (!key) {
|
|
188
|
+
return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: 'no OpenRouter key — skipped', hint: null };
|
|
189
|
+
}
|
|
190
|
+
// Reuses the #38 non-blocking probe; resolves warning:null on any failure.
|
|
191
|
+
const res = (await d.checkOpenRouterCredit(key)) || {};
|
|
192
|
+
if (res.warning) {
|
|
193
|
+
return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'warn', message: res.warning, hint: 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).' };
|
|
194
|
+
}
|
|
195
|
+
const remaining = (typeof res.limitRemaining === 'number') ? ` ($${res.limitRemaining} remaining)` : '';
|
|
196
|
+
return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: `credit ok${remaining}`, hint: null };
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
// #43: project-root sanity — warns when cwd looks like an app/install dir or lacks project markers.
|
|
200
|
+
checks.push(guard('project-root', 'Project root', () => {
|
|
201
|
+
const dir = d.getCwd();
|
|
202
|
+
const markers = d.readProjectMarkers(dir);
|
|
203
|
+
const { assessProjectRoot } = require('./utils/project-root-sanity');
|
|
204
|
+
const r = assessProjectRoot(dir, markers);
|
|
205
|
+
return { id: 'project-root', name: 'Project root', status: r.status, message: r.message, hint: r.hint };
|
|
206
|
+
}));
|
|
207
|
+
|
|
136
208
|
return checks;
|
|
137
209
|
}
|
|
138
210
|
|
|
@@ -151,14 +223,18 @@ function renderHuman(checks) {
|
|
|
151
223
|
}
|
|
152
224
|
|
|
153
225
|
/**
|
|
154
|
-
* `amicus doctor [--json]`. Injectable `runChecks` for tests.
|
|
155
|
-
*
|
|
226
|
+
* `amicus doctor [--json] [--fix]`. Injectable `runChecks` for tests.
|
|
227
|
+
* `--fix` (#56) self-heals fixable checks in place (electron via repairElectron).
|
|
228
|
+
* @param {{_:string[], json?:boolean, fix?:boolean}} args
|
|
156
229
|
* @param {(deps?:object)=>Array} [runChecks]
|
|
157
230
|
* @returns {Promise<number>} exit code
|
|
158
231
|
*/
|
|
159
232
|
async function handleDoctor(args, runChecks = runDoctorChecks) {
|
|
160
233
|
const useJson = !!args.json;
|
|
161
|
-
|
|
234
|
+
// #56: --fix flows into runDoctorChecks as a dep so fixable checks (electron)
|
|
235
|
+
// self-heal in place. Omitted (not false) when absent so the injected
|
|
236
|
+
// test-double sees a clean "no fix" call.
|
|
237
|
+
const checks = await runChecks(args.fix ? { fix: true } : undefined);
|
|
162
238
|
if (useJson) {
|
|
163
239
|
const { buildDoctorDoc } = require('./utils/result-schema');
|
|
164
240
|
const VERSION = require('../package.json').version;
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -173,6 +173,10 @@ async function handleFanout(args) {
|
|
|
173
173
|
contextTurns: args['context-turns'],
|
|
174
174
|
contextSince: args['context-since'],
|
|
175
175
|
contextMaxTokens: args['context-max-tokens'],
|
|
176
|
+
// #10: forward the Cowork parent so MCP-spawned fanout legs pin the right
|
|
177
|
+
// session (mirrors handleStart's coworkProcess plumbing). Without this the
|
|
178
|
+
// spawned `--cowork-process` flag is dropped and buildContext gets null.
|
|
179
|
+
coworkProcess: args['cowork-process'],
|
|
176
180
|
mcp: args.mcp,
|
|
177
181
|
mcpConfig: args['mcp-config'],
|
|
178
182
|
noMcp: args['no-mcp'],
|
package/src/cli.js
CHANGED
|
@@ -116,6 +116,7 @@ function isBooleanFlag(key) {
|
|
|
116
116
|
'no-ledger', // council tally: compute the record without appending to the reliability ledger
|
|
117
117
|
'html', // council report: emit a self-contained HTML page
|
|
118
118
|
'md', // council report: emit Markdown (default)
|
|
119
|
+
'fix', // doctor: self-heal fixable checks in place (#56)
|
|
119
120
|
];
|
|
120
121
|
return booleanFlags.includes(key);
|
|
121
122
|
}
|
|
@@ -404,6 +405,58 @@ Options for 'read':
|
|
|
404
405
|
--conversation Show full conversation
|
|
405
406
|
--metadata Show session metadata
|
|
406
407
|
--json Emit the run/wave result as stable JSON
|
|
408
|
+
`,
|
|
409
|
+
continue: `
|
|
410
|
+
Options for 'continue':
|
|
411
|
+
<task_id> Required. Session to build on (positional)
|
|
412
|
+
--prompt <text> Required. Briefing for the new session
|
|
413
|
+
--model <model> Optional. Override the model (alias or provider/model)
|
|
414
|
+
--cwd <path> Project directory (default: cwd)
|
|
415
|
+
--no-ui Run without GUI (autonomous mode)
|
|
416
|
+
--timeout <minutes> Headless timeout (default: 15)
|
|
417
|
+
--context-turns <N> Max conversation turns (default: 50)
|
|
418
|
+
--context-max-tokens <N> Max context tokens (default: 80000)
|
|
419
|
+
`,
|
|
420
|
+
resume: `
|
|
421
|
+
Options for 'resume':
|
|
422
|
+
<task_id> Required. Session to reopen (positional)
|
|
423
|
+
--cwd <path> Project directory (default: cwd)
|
|
424
|
+
--no-ui Run without GUI (autonomous mode)
|
|
425
|
+
--timeout <minutes> Headless timeout (default: 15)
|
|
426
|
+
`,
|
|
427
|
+
council: `
|
|
428
|
+
Subcommands for 'council':
|
|
429
|
+
tally <input.json> Tally findings → tiers/street-cred (appends to ledger)
|
|
430
|
+
--no-ledger Compute the record without appending to the ledger
|
|
431
|
+
--json Machine-readable output
|
|
432
|
+
stats Reviewer-reliability aggregates from the ledger
|
|
433
|
+
--json Machine-readable output
|
|
434
|
+
report <verdict.json> Disagreement + verdict report
|
|
435
|
+
--wave <wave.json> Include per-leg run stats from a wave file
|
|
436
|
+
--md Emit Markdown (default)
|
|
437
|
+
--html Emit a self-contained HTML page
|
|
438
|
+
`,
|
|
439
|
+
doctor: `
|
|
440
|
+
Options for 'doctor':
|
|
441
|
+
--json Machine-readable output
|
|
442
|
+
--fix Self-heal fixable checks in place (provisions the
|
|
443
|
+
Electron GUI binary; no global reinstall)
|
|
444
|
+
`,
|
|
445
|
+
setup: `
|
|
446
|
+
Options for 'setup':
|
|
447
|
+
(no args) Run the interactive setup wizard
|
|
448
|
+
--api-keys Open the API key setup window
|
|
449
|
+
--add-alias <name=model> Add a model alias without the full wizard
|
|
450
|
+
`,
|
|
451
|
+
key: `
|
|
452
|
+
Usage for 'key':
|
|
453
|
+
key <provider> <apikey> Validate and save a key
|
|
454
|
+
key <provider> --remove Remove a saved key
|
|
455
|
+
key List all configured providers
|
|
456
|
+
`,
|
|
457
|
+
mcp: `
|
|
458
|
+
Usage for 'mcp':
|
|
459
|
+
mcp Start the MCP server (stdio transport)
|
|
407
460
|
`
|
|
408
461
|
};
|
|
409
462
|
|
package/src/headless.js
CHANGED
|
@@ -99,6 +99,17 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
99
99
|
const sessionDir = getSessionDir(project, taskId);
|
|
100
100
|
const conversationPath = path.join(sessionDir, 'conversation.jsonl');
|
|
101
101
|
|
|
102
|
+
// #47: scope every per-session SDK call to the project directory so a SHARED
|
|
103
|
+
// OpenCode server (one server, many projects) files and finds this session
|
|
104
|
+
// under the right ?directory=. `dirArgs` is the trailing arg list for the
|
|
105
|
+
// positional client wrappers (createSession/getMessages/getSessionStatus/
|
|
106
|
+
// abortSession) and is EMPTY when no directory is supplied — so the un-scoped
|
|
107
|
+
// (owned-server) call shape stays byte-for-byte identical. A scoped create
|
|
108
|
+
// with un-scoped follow-ups reproduces the identical "session not found"
|
|
109
|
+
// failure, so ALL of them must carry it.
|
|
110
|
+
const { directory } = options;
|
|
111
|
+
const dirArgs = directory === undefined ? [] : [directory];
|
|
112
|
+
|
|
102
113
|
// Ensure session directory exists
|
|
103
114
|
if (!fs.existsSync(sessionDir)) {
|
|
104
115
|
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
@@ -204,7 +215,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
204
215
|
logger.debug('Using existing session', { sessionId });
|
|
205
216
|
} else {
|
|
206
217
|
try {
|
|
207
|
-
sessionId = await createSession(client);
|
|
218
|
+
sessionId = await createSession(client, ...dirArgs);
|
|
208
219
|
} catch (error) {
|
|
209
220
|
if (watchdog) { watchdog.cancel(); }
|
|
210
221
|
if (!externalServer) { server.close(); }
|
|
@@ -242,7 +253,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
242
253
|
markAborted(sessionDir, signal);
|
|
243
254
|
try {
|
|
244
255
|
const { abortSession } = require('./opencode-client');
|
|
245
|
-
abortSession(client, sessionId).catch(() => {});
|
|
256
|
+
abortSession(client, sessionId, ...dirArgs).catch(() => {});
|
|
246
257
|
} catch { /* best-effort */ }
|
|
247
258
|
try { server.close(); } catch { /* best-effort */ }
|
|
248
259
|
const { resolveTerminalState } = require('./sidecar/session-finalize');
|
|
@@ -272,6 +283,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
272
283
|
system: systemPrompt,
|
|
273
284
|
parts: [{ type: 'text', text: userMessage }]
|
|
274
285
|
};
|
|
286
|
+
// #47: scope the prompt to the project on a shared server. Only set when a
|
|
287
|
+
// directory was supplied so the owned-server options object is unchanged.
|
|
288
|
+
if (directory !== undefined) { promptOptions.directory = directory; }
|
|
275
289
|
|
|
276
290
|
// Default to 'build' in headless mode — 'chat' stalls without user interaction
|
|
277
291
|
const agentConfig = mapAgentToOpenCode(agent || 'build');
|
|
@@ -344,7 +358,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
344
358
|
logger.info('External abort signal received', { taskId });
|
|
345
359
|
try {
|
|
346
360
|
const { abortSession } = require('./opencode-client');
|
|
347
|
-
await abortSession(client, sessionId);
|
|
361
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
348
362
|
} catch (abortErr) {
|
|
349
363
|
logger.warn('Failed to abort OpenCode session', { error: abortErr.message });
|
|
350
364
|
}
|
|
@@ -360,7 +374,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
360
374
|
try {
|
|
361
375
|
const remaining = deadline - Date.now();
|
|
362
376
|
const messages = await withTimeout(
|
|
363
|
-
getMessages(client, sessionId),
|
|
377
|
+
getMessages(client, sessionId, ...dirArgs),
|
|
364
378
|
Math.min(pollCallTimeoutMs, remaining),
|
|
365
379
|
'getMessages'
|
|
366
380
|
);
|
|
@@ -420,7 +434,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
420
434
|
try {
|
|
421
435
|
const remainingForStatus = deadline - Date.now();
|
|
422
436
|
const statusData = await withTimeout(
|
|
423
|
-
getSessionStatus(client, sessionId),
|
|
437
|
+
getSessionStatus(client, sessionId, ...dirArgs),
|
|
424
438
|
Math.min(pollCallTimeoutMs, remainingForStatus),
|
|
425
439
|
'getSessionStatus'
|
|
426
440
|
);
|
|
@@ -509,7 +523,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
509
523
|
// Abort the OpenCode session on timeout (agent keeps running otherwise)
|
|
510
524
|
try {
|
|
511
525
|
const { abortSession } = require('./opencode-client');
|
|
512
|
-
await abortSession(client, sessionId);
|
|
526
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
513
527
|
logger.info('Session aborted after timeout', { taskId, sessionId });
|
|
514
528
|
} catch (abortErr) {
|
|
515
529
|
logger.warn('Failed to abort session after timeout', { error: abortErr.message });
|
|
@@ -572,7 +586,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
572
586
|
if (sessionId) {
|
|
573
587
|
try {
|
|
574
588
|
const { abortSession } = require('./opencode-client');
|
|
575
|
-
await abortSession(client, sessionId);
|
|
589
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
576
590
|
} catch {
|
|
577
591
|
// Ignore abort errors during error handling
|
|
578
592
|
}
|
package/src/mcp-server.js
CHANGED
|
@@ -11,6 +11,10 @@ const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-
|
|
|
11
11
|
const { readProgress, isStalled } = require('./sidecar/progress');
|
|
12
12
|
const { SharedServerManager } = require('./utils/shared-server');
|
|
13
13
|
const { durationBetween } = require('./utils/result-schema');
|
|
14
|
+
const { canonicalProjectPath } = require('./utils/project-path');
|
|
15
|
+
const { recordSession } = require('./utils/session-index');
|
|
16
|
+
const { fileURLToPath } = require('url');
|
|
17
|
+
const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* Elapsed run duration: time between createdAt and the run's end, bounding the
|
|
@@ -24,15 +28,104 @@ function elapsedMs(metadata) {
|
|
|
24
28
|
return durationBetween(metadata.createdAt, end) ?? 0;
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
// Non-complete terminal statuses: a run that ended in one of these failed (or
|
|
32
|
+
// was stopped) and may have no usable summary. amicus_read surfaces
|
|
33
|
+
// metadata.reason for these instead of a bare "No summary available" (#36).
|
|
34
|
+
// 'timed-out' is the canonical single-session value persisted by
|
|
35
|
+
// resolveTerminalState/finalizeHeadlessResult (session-finalize.js); 'timeout'
|
|
36
|
+
// is the wave/leg value from statusFromResult (kept here for defensive
|
|
37
|
+
// coverage); 'idle-timeout' is the shared-server idle-eviction value.
|
|
38
|
+
const FAILED_TERMINAL_STATUSES = ['error', 'crashed', 'timeout', 'timed-out', 'idle-timeout', 'aborted'];
|
|
39
|
+
|
|
27
40
|
const sharedServer = new SharedServerManager({ logger });
|
|
28
41
|
|
|
29
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the project directory synchronously.
|
|
44
|
+
*
|
|
45
|
+
* Resolution order (the MCP-roots step is async and lives in resolveProjectDir,
|
|
46
|
+
* which slots between the env override and the cwd fallback here):
|
|
47
|
+
* explicit project arg → AMICUS_PROJECT_DIR env → process.cwd() → $HOME.
|
|
48
|
+
*
|
|
49
|
+
* A stdio MCP server spawned by a desktop app inherits the APP INSTALL DIR as
|
|
50
|
+
* cwd, so AMICUS_PROJECT_DIR lets the launcher pin the real project before the
|
|
51
|
+
* cwd fallback ever fires. The resolved path is canonicalized so it matches
|
|
52
|
+
* however a later lookup spells the same directory.
|
|
53
|
+
*/
|
|
30
54
|
function getProjectDir(explicitProject) {
|
|
31
|
-
if (explicitProject && fs.existsSync(explicitProject)) {
|
|
55
|
+
if (explicitProject && fs.existsSync(explicitProject)) {
|
|
56
|
+
return canonicalProjectPath(explicitProject);
|
|
57
|
+
}
|
|
58
|
+
const envProject = process.env.AMICUS_PROJECT_DIR;
|
|
59
|
+
if (envProject && fs.existsSync(envProject)) {
|
|
60
|
+
return canonicalProjectPath(envProject);
|
|
61
|
+
}
|
|
32
62
|
const cwd = process.cwd();
|
|
33
|
-
if (cwd !== '/' && fs.existsSync(cwd)) { return cwd; }
|
|
63
|
+
if (cwd !== '/' && fs.existsSync(cwd)) { return canonicalProjectPath(cwd); }
|
|
34
64
|
if (cwd === '/') { logger.warn('cwd is root (/), falling back to $HOME'); }
|
|
35
|
-
return os.homedir();
|
|
65
|
+
return canonicalProjectPath(os.homedir());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Cache the client's roots/list result once per server so concurrent tool
|
|
69
|
+
// calls don't each pay a round-trip. Keyed by the McpServer wrapper so distinct
|
|
70
|
+
// servers (e.g. across tests) don't share state.
|
|
71
|
+
const _rootsCache = new WeakMap();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fetch the client's first file:// root via a roots/list round-trip, cached.
|
|
75
|
+
* Returns a canonical path string, or null when roots are unavailable
|
|
76
|
+
* (no client, no roots capability, empty list, non-file roots, or an error).
|
|
77
|
+
* @param {object} mcpServer - the McpServer wrapper exposing `.server`.
|
|
78
|
+
* @returns {Promise<string|null>}
|
|
79
|
+
*/
|
|
80
|
+
async function getClientRoot(mcpServer) {
|
|
81
|
+
const core = mcpServer && mcpServer.server;
|
|
82
|
+
if (!core || typeof core.listRoots !== 'function') { return null; }
|
|
83
|
+
if (_rootsCache.has(mcpServer)) { return _rootsCache.get(mcpServer); }
|
|
84
|
+
|
|
85
|
+
let resolved = null;
|
|
86
|
+
try {
|
|
87
|
+
const caps = typeof core.getClientCapabilities === 'function'
|
|
88
|
+
? core.getClientCapabilities() : undefined;
|
|
89
|
+
if (caps && caps.roots) {
|
|
90
|
+
const { roots } = await core.listRoots();
|
|
91
|
+
const fileRoot = Array.isArray(roots)
|
|
92
|
+
? roots.find((r) => r && typeof r.uri === 'string' && r.uri.startsWith('file:'))
|
|
93
|
+
: null;
|
|
94
|
+
if (fileRoot) {
|
|
95
|
+
const p = fileURLToPath(fileRoot.uri);
|
|
96
|
+
if (fs.existsSync(p)) { resolved = canonicalProjectPath(p); }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger.warn('roots/list failed, falling back to cwd', { error: err.message });
|
|
101
|
+
}
|
|
102
|
+
_rootsCache.set(mcpServer, resolved);
|
|
103
|
+
return resolved;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the project directory, consulting the MCP client's roots when no
|
|
108
|
+
* explicit project / env override is given.
|
|
109
|
+
*
|
|
110
|
+
* Order: explicit project arg → AMICUS_PROJECT_DIR env → client first file://
|
|
111
|
+
* root → process.cwd() → $HOME. All branches are canonicalized.
|
|
112
|
+
* @param {string|undefined} explicitProject
|
|
113
|
+
* @param {object} [mcpServer] - the McpServer wrapper (for the roots round-trip).
|
|
114
|
+
* @returns {Promise<string>}
|
|
115
|
+
*/
|
|
116
|
+
async function resolveProjectDir(explicitProject, mcpServer) {
|
|
117
|
+
if (explicitProject && fs.existsSync(explicitProject)) {
|
|
118
|
+
return canonicalProjectPath(explicitProject);
|
|
119
|
+
}
|
|
120
|
+
const envProject = process.env.AMICUS_PROJECT_DIR;
|
|
121
|
+
if (envProject && fs.existsSync(envProject)) {
|
|
122
|
+
return canonicalProjectPath(envProject);
|
|
123
|
+
}
|
|
124
|
+
if (mcpServer) {
|
|
125
|
+
const root = await getClientRoot(mcpServer);
|
|
126
|
+
if (root) { return root; }
|
|
127
|
+
}
|
|
128
|
+
return getProjectDir(undefined);
|
|
36
129
|
}
|
|
37
130
|
|
|
38
131
|
/** Read session metadata from disk, or null if not found */
|
|
@@ -50,6 +143,17 @@ function textResult(text, isError) {
|
|
|
50
143
|
return result;
|
|
51
144
|
}
|
|
52
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Append a stale-version warning content block (#33) when the on-disk
|
|
148
|
+
* package.json has been upgraded under the running process. No-op when in
|
|
149
|
+
* sync or unreadable (versionWarning() returns null). Mutates `content`.
|
|
150
|
+
*/
|
|
151
|
+
function appendVersionWarning(content) {
|
|
152
|
+
const warn = versionWarning();
|
|
153
|
+
if (warn) { content.push({ type: 'text', text: warn }); }
|
|
154
|
+
return content;
|
|
155
|
+
}
|
|
156
|
+
|
|
53
157
|
/**
|
|
54
158
|
* Compute next poll hint for headless sessions.
|
|
55
159
|
* @returns {{ hint: string }}
|
|
@@ -140,10 +244,16 @@ const handlers = {
|
|
|
140
244
|
const { finalizeHeadlessResult } = require('./sidecar/session-finalize');
|
|
141
245
|
// resolvedModel is already available from validateStartInputs() above
|
|
142
246
|
|
|
143
|
-
|
|
247
|
+
// #47: the shared OpenCode server is shared across projects, so the
|
|
248
|
+
// session must be created scoped to the resolved project directory
|
|
249
|
+
// (cwd, already canonicalized by getProjectDir/#39) — otherwise it is
|
|
250
|
+
// found by id but NOT by a ?directory= query. runHeadless then scopes
|
|
251
|
+
// every follow-up call to the SAME directory (passed via options.directory).
|
|
252
|
+
sessionId = await createSession(client, cwd);
|
|
144
253
|
|
|
145
254
|
// Write initial metadata (MCP handler owns this, runHeadless skips it)
|
|
146
255
|
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
256
|
+
recordSession(taskId, cwd); // #40: global index for cross-project lookup
|
|
147
257
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
148
258
|
const serverPort = server.url ? new URL(server.url).port : null;
|
|
149
259
|
fs.writeFileSync(metaPath, JSON.stringify({
|
|
@@ -195,6 +305,7 @@ const handlers = {
|
|
|
195
305
|
runHeadless(resolvedModel, systemPrompt, userMessage, taskId, cwd,
|
|
196
306
|
timeoutMs, agent, {
|
|
197
307
|
client, server, watchdog, sessionId,
|
|
308
|
+
directory: cwd, // #47: scope every per-session follow-up call to the project
|
|
198
309
|
mcp: undefined, // shared server already has MCP config
|
|
199
310
|
}
|
|
200
311
|
).then((result) => {
|
|
@@ -246,6 +357,7 @@ const handlers = {
|
|
|
246
357
|
|
|
247
358
|
if (child && child.pid) {
|
|
248
359
|
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
360
|
+
recordSession(taskId, cwd); // #40: global index for cross-project lookup
|
|
249
361
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
250
362
|
if (!fs.existsSync(metaPath)) {
|
|
251
363
|
fs.writeFileSync(metaPath, JSON.stringify({
|
|
@@ -329,15 +441,17 @@ const handlers = {
|
|
|
329
441
|
taskId: metadata.taskId, type: 'wave', status: metadata.status,
|
|
330
442
|
legsComplete: done, legsTotal: legs.length, legs,
|
|
331
443
|
elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
|
|
444
|
+
version: RUNNING_VERSION,
|
|
332
445
|
};
|
|
333
446
|
if (metadata.status === 'crashed' || metadata.status === 'error') {
|
|
334
447
|
response.reason = metadata.reason || 'Unknown error';
|
|
335
448
|
}
|
|
336
|
-
const
|
|
449
|
+
const content = [{ type: 'text', text: JSON.stringify(response) }];
|
|
450
|
+
appendVersionWarning(content);
|
|
337
451
|
if (metadata.status === 'running') {
|
|
338
|
-
|
|
452
|
+
content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
|
|
339
453
|
}
|
|
340
|
-
return
|
|
454
|
+
return { content };
|
|
341
455
|
}
|
|
342
456
|
|
|
343
457
|
if (metadata.status === 'running' && metadata.pid) {
|
|
@@ -355,6 +469,7 @@ const handlers = {
|
|
|
355
469
|
const response = {
|
|
356
470
|
taskId: metadata.taskId, status: metadata.status,
|
|
357
471
|
elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
|
|
472
|
+
version: RUNNING_VERSION,
|
|
358
473
|
};
|
|
359
474
|
if (metadata.model) { response.model = metadata.model; }
|
|
360
475
|
|
|
@@ -379,11 +494,12 @@ const handlers = {
|
|
|
379
494
|
if (metadata.status === 'crashed' || metadata.status === 'error') {
|
|
380
495
|
response.reason = metadata.reason || 'Unknown error';
|
|
381
496
|
}
|
|
382
|
-
const
|
|
497
|
+
const content = [{ type: 'text', text: JSON.stringify(response) }];
|
|
498
|
+
appendVersionWarning(content);
|
|
383
499
|
if (metadata.status === 'running' && metadata.headless) {
|
|
384
|
-
|
|
500
|
+
content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
|
|
385
501
|
}
|
|
386
|
-
return
|
|
502
|
+
return { content };
|
|
387
503
|
},
|
|
388
504
|
|
|
389
505
|
async amicus_read(input, project) {
|
|
@@ -423,21 +539,25 @@ const handlers = {
|
|
|
423
539
|
}
|
|
424
540
|
// Default: summary
|
|
425
541
|
const summaryPath = path.join(sessionDir, 'summary.md');
|
|
426
|
-
if (!fs.existsSync(summaryPath)) {
|
|
427
|
-
return textResult('No summary available (session may still be running or was not folded).');
|
|
428
|
-
}
|
|
429
542
|
const metaForRead = (() => {
|
|
430
543
|
try { return JSON.parse(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8')); }
|
|
431
544
|
catch { return {}; }
|
|
432
545
|
})();
|
|
433
|
-
const summaryText = fs.
|
|
546
|
+
const summaryText = fs.existsSync(summaryPath)
|
|
547
|
+
? fs.readFileSync(summaryPath, 'utf-8')
|
|
548
|
+
: '';
|
|
434
549
|
const header = metaForRead.model ? `**Model:** ${metaForRead.model}\n\n` : '';
|
|
435
|
-
// A
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
550
|
+
// A run that ended in a failed terminal status may have no usable summary:
|
|
551
|
+
// a crashed/timed-out run never writes summary.md, and a fast-failed
|
|
552
|
+
// shared-server run writes an EXISTING 0-byte summary.md. In both cases
|
|
553
|
+
// surface metadata.reason instead of a bare "No summary available" or an
|
|
554
|
+
// empty body (#36). Complete/partial-summary runs are unaffected.
|
|
555
|
+
if (FAILED_TERMINAL_STATUSES.includes(metaForRead.status) && !summaryText.trim()) {
|
|
439
556
|
const reason = metaForRead.reason || 'Unknown error';
|
|
440
|
-
return textResult(`${header}**Status:** ${metaForRead.status}\n**Reason:** ${reason}\n\n(No summary — the session ended
|
|
557
|
+
return textResult(`${header}**Status:** ${metaForRead.status}\n**Reason:** ${reason}\n\n(No summary — the session ended in status '${metaForRead.status}'.)`);
|
|
558
|
+
}
|
|
559
|
+
if (!summaryText.trim()) {
|
|
560
|
+
return textResult('No summary available (session may still be running or was not folded).');
|
|
441
561
|
}
|
|
442
562
|
return textResult(header + summaryText);
|
|
443
563
|
},
|
|
@@ -521,6 +641,7 @@ const handlers = {
|
|
|
521
641
|
try { spawnSidecarProcess(args, sessionDir); } catch (err) {
|
|
522
642
|
return textResult(`Failed to continue: ${err.message}`, true);
|
|
523
643
|
}
|
|
644
|
+
recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
|
|
524
645
|
return textResult(JSON.stringify({
|
|
525
646
|
taskId: newTaskId, status: 'running',
|
|
526
647
|
message: 'Continuation started. Use amicus_status to check progress.',
|
|
@@ -530,7 +651,10 @@ const handlers = {
|
|
|
530
651
|
async amicus_abort(input, project) {
|
|
531
652
|
const cwd = project || getProjectDir(input.project);
|
|
532
653
|
const metadata = readMetadata(input.taskId, cwd);
|
|
533
|
-
if (!metadata) {
|
|
654
|
+
if (!metadata) {
|
|
655
|
+
return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
|
|
656
|
+
'If you ran it in a different project, pass the original "project".', true);
|
|
657
|
+
}
|
|
534
658
|
if (metadata.status !== 'running') {
|
|
535
659
|
return textResult(`Session ${input.taskId} is not running (status: ${metadata.status}).`);
|
|
536
660
|
}
|
|
@@ -602,6 +726,10 @@ const handlers = {
|
|
|
602
726
|
taskId: waveId, type: 'wave', status: 'running', legs: legIds,
|
|
603
727
|
models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
|
|
604
728
|
}, null, 2), { mode: 0o600 });
|
|
729
|
+
// #40: index the wave AND each leg so status/read of any leg resolves the
|
|
730
|
+
// project even when the default later defaults to a different one.
|
|
731
|
+
recordSession(waveId, cwd);
|
|
732
|
+
for (const legId of legIds) { recordSession(legId, cwd); }
|
|
605
733
|
} catch (err) {
|
|
606
734
|
return textResult(`Failed to prepare fan-out wave: ${err.message}`, true);
|
|
607
735
|
}
|
|
@@ -617,6 +745,10 @@ const handlers = {
|
|
|
617
745
|
if (input.timeout) { args.push('--timeout', String(input.timeout)); }
|
|
618
746
|
if (input.summaryLength) { args.push('--summary-length', input.summaryLength); }
|
|
619
747
|
if (input.includeContext === false) { args.push('--no-context'); }
|
|
748
|
+
// #10: forward cowork session pinning to the legs (parity with amicus_start),
|
|
749
|
+
// so context-inheriting fanout launched from Cowork resolves the right parent.
|
|
750
|
+
if (input.coworkProcess) { args.push('--cowork-process', input.coworkProcess); }
|
|
751
|
+
if (input.parentSession) { args.push('--session-id', input.parentSession); }
|
|
620
752
|
|
|
621
753
|
try { spawnSidecarProcess(args, waveDir); } catch (err) {
|
|
622
754
|
// Best-effort: never leave a pid-less wave record claiming 'running'
|
|
@@ -697,14 +829,22 @@ const LEGACY_TOOL_ALIASES = {
|
|
|
697
829
|
async function startMcpServer() {
|
|
698
830
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
699
831
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
700
|
-
const server = new McpServer(
|
|
832
|
+
const server = new McpServer(
|
|
833
|
+
{ name: 'amicus', version: require('../package.json').version },
|
|
834
|
+
// Declare the `roots` capability so the client advertises its roots and we
|
|
835
|
+
// can request them (roots/list) when no explicit project is supplied.
|
|
836
|
+
{ capabilities: { roots: {} } }
|
|
837
|
+
);
|
|
701
838
|
|
|
702
839
|
for (const tool of getTools()) {
|
|
703
840
|
const register = (name) => server.registerTool(
|
|
704
841
|
name,
|
|
705
842
|
{ description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations },
|
|
706
843
|
async (input) => {
|
|
707
|
-
try {
|
|
844
|
+
try {
|
|
845
|
+
const project = await resolveProjectDir(input.project, server);
|
|
846
|
+
return await handlers[tool.name](input, project);
|
|
847
|
+
}
|
|
708
848
|
catch (err) {
|
|
709
849
|
logger.error(`MCP tool error: ${name}`, { error: err.message });
|
|
710
850
|
return textResult(`Error: ${err.message}`, true);
|
|
@@ -727,4 +867,7 @@ async function startMcpServer() {
|
|
|
727
867
|
process.stderr.write('[amicus] MCP server running on stdio\n');
|
|
728
868
|
}
|
|
729
869
|
|
|
730
|
-
module.exports = {
|
|
870
|
+
module.exports = {
|
|
871
|
+
handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
|
|
872
|
+
LEGACY_TOOL_ALIASES,
|
|
873
|
+
};
|