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.
@@ -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: 'npm install -g amicus (a transient install error can roll back the engine binaries — re-run, or: npm cache clean --force && npm install -g amicus)' }
131
+ : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngine }
109
132
  )));
110
133
 
111
- checks.push(guard('electron', 'Electron (interactive GUI)', () => (
112
- d.getElectronPath()
113
- ? { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null }
114
- : { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: 'npm install -g amicus (reinstall to add Electron)' }
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: 'npm install -g amicus (re-runs the skill install)' }
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: 'npm install -g amicus (or install the amicus plugin)' };
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
- * @param {{_:string[], json?:boolean}} args
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
- const checks = runChecks();
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;
@@ -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
- /** Resolve the project directory with smart fallback. */
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)) { return 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
- sessionId = await createSession(client);
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 responseText = JSON.stringify(response);
449
+ const content = [{ type: 'text', text: JSON.stringify(response) }];
450
+ appendVersionWarning(content);
337
451
  if (metadata.status === 'running') {
338
- return { content: [{ type: 'text', text: responseText }, { type: 'text', text: HEADLESS_STATUS_REMINDER }] };
452
+ content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
339
453
  }
340
- return textResult(responseText);
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 responseText = JSON.stringify(response);
497
+ const content = [{ type: 'text', text: JSON.stringify(response) }];
498
+ appendVersionWarning(content);
383
499
  if (metadata.status === 'running' && metadata.headless) {
384
- return { content: [{ type: 'text', text: responseText }, { type: 'text', text: HEADLESS_STATUS_REMINDER }] };
500
+ content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
385
501
  }
386
- return textResult(responseText);
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.readFileSync(summaryPath, 'utf-8');
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 failed shared-server run writes an EXISTING 0-byte summary.md (so we hit
436
- // this file-exists branch, not "No summary available"). Surface the failure
437
- // reason instead of returning an empty/uninformative body (#36).
438
- if ((metaForRead.status === 'error' || metaForRead.status === 'crashed') && !summaryText.trim()) {
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 with an error.)`);
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) { return textResult(`Session ${input.taskId} not found.`, true); }
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({ name: 'amicus', version: require('../package.json').version });
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 { return await handlers[tool.name](input, getProjectDir(input.project)); }
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 = { handlers, startMcpServer, getProjectDir, LEGACY_TOOL_ALIASES };
870
+ module.exports = {
871
+ handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
872
+ LEGACY_TOOL_ALIASES,
873
+ };