amicus 1.5.1 → 1.6.1

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/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');
@@ -289,7 +303,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
289
303
  agent: promptOptions.agent,
290
304
  userMessageLength: userMessage.length
291
305
  });
292
- await sendPromptAsync(client, sessionId, promptOptions);
306
+ const promptResult = await sendPromptAsync(client, sessionId, promptOptions);
293
307
  writeProgress(sessionDir, 'prompt_sent');
294
308
  logger.info('Prompt sent successfully, entering polling loop', {
295
309
  sessionId,
@@ -302,6 +316,16 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
302
316
  let aborted = false;
303
317
  let sessionError = null; // Captures model/SDK errors from assistant messages
304
318
 
319
+ // Hard provider failure detected at the client boundary (#37): a non-2xx /
320
+ // 402 from promptAsync surfaces here even when the server never emits an
321
+ // assistant message carrying info.error. Seed sessionError so the loop's
322
+ // "error with no output" gate ends the run promptly with a usable reason.
323
+ const boundaryProviderError = !!(promptResult && promptResult.providerError);
324
+ if (boundaryProviderError) {
325
+ sessionError = promptResult.providerError;
326
+ logger.error('Provider error at client boundary', { taskId, sessionId, reason: sessionError });
327
+ }
328
+
305
329
  // Poll for completion by checking messages
306
330
  const startTime = Date.now();
307
331
  const deadline = startTime + timeoutMs;
@@ -334,7 +358,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
334
358
  logger.info('External abort signal received', { taskId });
335
359
  try {
336
360
  const { abortSession } = require('./opencode-client');
337
- await abortSession(client, sessionId);
361
+ await abortSession(client, sessionId, ...dirArgs);
338
362
  } catch (abortErr) {
339
363
  logger.warn('Failed to abort OpenCode session', { error: abortErr.message });
340
364
  }
@@ -350,7 +374,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
350
374
  try {
351
375
  const remaining = deadline - Date.now();
352
376
  const messages = await withTimeout(
353
- getMessages(client, sessionId),
377
+ getMessages(client, sessionId, ...dirArgs),
354
378
  Math.min(pollCallTimeoutMs, remaining),
355
379
  'getMessages'
356
380
  );
@@ -383,6 +407,17 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
383
407
  break;
384
408
  }
385
409
 
410
+ // Hard provider failure at the client boundary (#37): the request was
411
+ // rejected (e.g., 402) so no assistant message will ever arrive. Exit as
412
+ // soon as we confirm nothing streamed — don't wait for assistantFinished
413
+ // (which never flips here) or the full timeout.
414
+ if (boundaryProviderError && !mirror.output) {
415
+ logger.error('Provider error at boundary with no output, exiting', {
416
+ sessionError, pollCount
417
+ });
418
+ break;
419
+ }
420
+
386
421
  // If the model returned an error with no output, exit immediately
387
422
  // (don't wait for timeout — the model won't produce anything)
388
423
  if (sessionError && !mirror.output && assistantFinished) {
@@ -399,7 +434,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
399
434
  try {
400
435
  const remainingForStatus = deadline - Date.now();
401
436
  const statusData = await withTimeout(
402
- getSessionStatus(client, sessionId),
437
+ getSessionStatus(client, sessionId, ...dirArgs),
403
438
  Math.min(pollCallTimeoutMs, remainingForStatus),
404
439
  'getSessionStatus'
405
440
  );
@@ -488,7 +523,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
488
523
  // Abort the OpenCode session on timeout (agent keeps running otherwise)
489
524
  try {
490
525
  const { abortSession } = require('./opencode-client');
491
- await abortSession(client, sessionId);
526
+ await abortSession(client, sessionId, ...dirArgs);
492
527
  logger.info('Session aborted after timeout', { taskId, sessionId });
493
528
  } catch (abortErr) {
494
529
  logger.warn('Failed to abort session after timeout', { error: abortErr.message });
@@ -551,7 +586,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
551
586
  if (sessionId) {
552
587
  try {
553
588
  const { abortSession } = require('./opencode-client');
554
- await abortSession(client, sessionId);
589
+ await abortSession(client, sessionId, ...dirArgs);
555
590
  } catch {
556
591
  // Ignore abort errors during error handling
557
592
  }
package/src/mcp-server.js CHANGED
@@ -10,16 +10,121 @@ const { safeSessionDir } = require('./utils/validators');
10
10
  const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
11
11
  const { readProgress, isStalled } = require('./sidecar/progress');
12
12
  const { SharedServerManager } = require('./utils/shared-server');
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
+
18
+ /**
19
+ * Elapsed run duration: time between createdAt and the run's end, bounding the
20
+ * end by completedAt/abortedAt/crashedAt so a delayed poll of a finished run
21
+ * reports the run duration, not time-since-start. Falls back to now() while
22
+ * still running. Returns 0 if createdAt is missing/malformed.
23
+ */
24
+ function elapsedMs(metadata) {
25
+ const end = metadata.completedAt || metadata.abortedAt || metadata.crashedAt
26
+ || new Date().toISOString();
27
+ return durationBetween(metadata.createdAt, end) ?? 0;
28
+ }
29
+
30
+ // Non-complete terminal statuses: a run that ended in one of these failed (or
31
+ // was stopped) and may have no usable summary. amicus_read surfaces
32
+ // metadata.reason for these instead of a bare "No summary available" (#36).
33
+ // 'timed-out' is the canonical single-session value persisted by
34
+ // resolveTerminalState/finalizeHeadlessResult (session-finalize.js); 'timeout'
35
+ // is the wave/leg value from statusFromResult (kept here for defensive
36
+ // coverage); 'idle-timeout' is the shared-server idle-eviction value.
37
+ const FAILED_TERMINAL_STATUSES = ['error', 'crashed', 'timeout', 'timed-out', 'idle-timeout', 'aborted'];
13
38
 
14
39
  const sharedServer = new SharedServerManager({ logger });
15
40
 
16
- /** Resolve the project directory with smart fallback. */
41
+ /**
42
+ * Resolve the project directory synchronously.
43
+ *
44
+ * Resolution order (the MCP-roots step is async and lives in resolveProjectDir,
45
+ * which slots between the env override and the cwd fallback here):
46
+ * explicit project arg → AMICUS_PROJECT_DIR env → process.cwd() → $HOME.
47
+ *
48
+ * A stdio MCP server spawned by a desktop app inherits the APP INSTALL DIR as
49
+ * cwd, so AMICUS_PROJECT_DIR lets the launcher pin the real project before the
50
+ * cwd fallback ever fires. The resolved path is canonicalized so it matches
51
+ * however a later lookup spells the same directory.
52
+ */
17
53
  function getProjectDir(explicitProject) {
18
- if (explicitProject && fs.existsSync(explicitProject)) { return explicitProject; }
54
+ if (explicitProject && fs.existsSync(explicitProject)) {
55
+ return canonicalProjectPath(explicitProject);
56
+ }
57
+ const envProject = process.env.AMICUS_PROJECT_DIR;
58
+ if (envProject && fs.existsSync(envProject)) {
59
+ return canonicalProjectPath(envProject);
60
+ }
19
61
  const cwd = process.cwd();
20
- if (cwd !== '/' && fs.existsSync(cwd)) { return cwd; }
62
+ if (cwd !== '/' && fs.existsSync(cwd)) { return canonicalProjectPath(cwd); }
21
63
  if (cwd === '/') { logger.warn('cwd is root (/), falling back to $HOME'); }
22
- return os.homedir();
64
+ return canonicalProjectPath(os.homedir());
65
+ }
66
+
67
+ // Cache the client's roots/list result once per server so concurrent tool
68
+ // calls don't each pay a round-trip. Keyed by the McpServer wrapper so distinct
69
+ // servers (e.g. across tests) don't share state.
70
+ const _rootsCache = new WeakMap();
71
+
72
+ /**
73
+ * Fetch the client's first file:// root via a roots/list round-trip, cached.
74
+ * Returns a canonical path string, or null when roots are unavailable
75
+ * (no client, no roots capability, empty list, non-file roots, or an error).
76
+ * @param {object} mcpServer - the McpServer wrapper exposing `.server`.
77
+ * @returns {Promise<string|null>}
78
+ */
79
+ async function getClientRoot(mcpServer) {
80
+ const core = mcpServer && mcpServer.server;
81
+ if (!core || typeof core.listRoots !== 'function') { return null; }
82
+ if (_rootsCache.has(mcpServer)) { return _rootsCache.get(mcpServer); }
83
+
84
+ let resolved = null;
85
+ try {
86
+ const caps = typeof core.getClientCapabilities === 'function'
87
+ ? core.getClientCapabilities() : undefined;
88
+ if (caps && caps.roots) {
89
+ const { roots } = await core.listRoots();
90
+ const fileRoot = Array.isArray(roots)
91
+ ? roots.find((r) => r && typeof r.uri === 'string' && r.uri.startsWith('file:'))
92
+ : null;
93
+ if (fileRoot) {
94
+ const p = fileURLToPath(fileRoot.uri);
95
+ if (fs.existsSync(p)) { resolved = canonicalProjectPath(p); }
96
+ }
97
+ }
98
+ } catch (err) {
99
+ logger.warn('roots/list failed, falling back to cwd', { error: err.message });
100
+ }
101
+ _rootsCache.set(mcpServer, resolved);
102
+ return resolved;
103
+ }
104
+
105
+ /**
106
+ * Resolve the project directory, consulting the MCP client's roots when no
107
+ * explicit project / env override is given.
108
+ *
109
+ * Order: explicit project arg → AMICUS_PROJECT_DIR env → client first file://
110
+ * root → process.cwd() → $HOME. All branches are canonicalized.
111
+ * @param {string|undefined} explicitProject
112
+ * @param {object} [mcpServer] - the McpServer wrapper (for the roots round-trip).
113
+ * @returns {Promise<string>}
114
+ */
115
+ async function resolveProjectDir(explicitProject, mcpServer) {
116
+ if (explicitProject && fs.existsSync(explicitProject)) {
117
+ return canonicalProjectPath(explicitProject);
118
+ }
119
+ const envProject = process.env.AMICUS_PROJECT_DIR;
120
+ if (envProject && fs.existsSync(envProject)) {
121
+ return canonicalProjectPath(envProject);
122
+ }
123
+ if (mcpServer) {
124
+ const root = await getClientRoot(mcpServer);
125
+ if (root) { return root; }
126
+ }
127
+ return getProjectDir(undefined);
23
128
  }
24
129
 
25
130
  /** Read session metadata from disk, or null if not found */
@@ -124,13 +229,19 @@ const handlers = {
124
229
  const { buildContext } = require('./sidecar/context-builder');
125
230
  const { buildPrompts } = require('./prompt-builder');
126
231
  const { runHeadless } = require('./headless');
127
- const { finalizeSession } = require('./sidecar/session-utils');
232
+ const { finalizeHeadlessResult } = require('./sidecar/session-finalize');
128
233
  // resolvedModel is already available from validateStartInputs() above
129
234
 
130
- sessionId = await createSession(client);
235
+ // #47: the shared OpenCode server is shared across projects, so the
236
+ // session must be created scoped to the resolved project directory
237
+ // (cwd, already canonicalized by getProjectDir/#39) — otherwise it is
238
+ // found by id but NOT by a ?directory= query. runHeadless then scopes
239
+ // every follow-up call to the SAME directory (passed via options.directory).
240
+ sessionId = await createSession(client, cwd);
131
241
 
132
242
  // Write initial metadata (MCP handler owns this, runHeadless skips it)
133
243
  fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
244
+ recordSession(taskId, cwd); // #40: global index for cross-project lookup
134
245
  const metaPath = path.join(sessionDir, 'metadata.json');
135
246
  const serverPort = server.url ? new URL(server.url).port : null;
136
247
  fs.writeFileSync(metaPath, JSON.stringify({
@@ -182,13 +293,16 @@ const handlers = {
182
293
  runHeadless(resolvedModel, systemPrompt, userMessage, taskId, cwd,
183
294
  timeoutMs, agent, {
184
295
  client, server, watchdog, sessionId,
296
+ directory: cwd, // #47: scope every per-session follow-up call to the project
185
297
  mcp: undefined, // shared server already has MCP config
186
298
  }
187
299
  ).then((result) => {
188
- // Session complete - finalize and remove from tracking
300
+ // Session done route through resolveTerminalState (same single source
301
+ // of truth as the CLI start.js path) so an errored/timed-out/aborted run
302
+ // can never silently default to 'complete' with a 0-byte summary (#36).
189
303
  try {
190
304
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
191
- finalizeSession(sessionDir, result.summary || '', cwd, meta);
305
+ finalizeHeadlessResult(sessionDir, result, cwd, meta);
192
306
  } catch (finErr) {
193
307
  logger.warn('Failed to finalize session', { error: finErr.message });
194
308
  }
@@ -231,6 +345,7 @@ const handlers = {
231
345
 
232
346
  if (child && child.pid) {
233
347
  fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
348
+ recordSession(taskId, cwd); // #40: global index for cross-project lookup
234
349
  const metaPath = path.join(sessionDir, 'metadata.json');
235
350
  if (!fs.existsSync(metaPath)) {
236
351
  fs.writeFileSync(metaPath, JSON.stringify({
@@ -259,7 +374,10 @@ const handlers = {
259
374
  const cwd = project || getProjectDir(input.project);
260
375
  const sessionDir = safeSessionDir(cwd, input.taskId);
261
376
  const metadata = readMetadata(input.taskId, cwd);
262
- if (!metadata) { return textResult(`Session ${input.taskId} not found.`, true); }
377
+ if (!metadata) {
378
+ return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
379
+ 'If you ran it in a different project, pass the original "project".', true);
380
+ }
263
381
 
264
382
  if (metadata.type === 'wave') {
265
383
  const legs = (metadata.legs || []).map((legId) => {
@@ -306,7 +424,7 @@ const handlers = {
306
424
  }
307
425
  }
308
426
 
309
- const ms = Date.now() - new Date(metadata.createdAt).getTime();
427
+ const ms = elapsedMs(metadata);
310
428
  const response = {
311
429
  taskId: metadata.taskId, type: 'wave', status: metadata.status,
312
430
  legsComplete: done, legsTotal: legs.length, legs,
@@ -333,7 +451,7 @@ const handlers = {
333
451
  }
334
452
  }
335
453
 
336
- const ms = Date.now() - new Date(metadata.createdAt).getTime();
454
+ const ms = elapsedMs(metadata);
337
455
  const response = {
338
456
  taskId: metadata.taskId, status: metadata.status,
339
457
  elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
@@ -372,7 +490,8 @@ const handlers = {
372
490
  const cwd = project || getProjectDir(input.project);
373
491
  const sessionDir = safeSessionDir(cwd, input.taskId);
374
492
  if (!fs.existsSync(sessionDir)) {
375
- return textResult(`Session ${input.taskId} not found.`, true);
493
+ return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
494
+ 'If you ran it in a different project, pass the original "project".', true);
376
495
  }
377
496
 
378
497
  const readMeta = (() => {
@@ -404,15 +523,26 @@ const handlers = {
404
523
  }
405
524
  // Default: summary
406
525
  const summaryPath = path.join(sessionDir, 'summary.md');
407
- if (!fs.existsSync(summaryPath)) {
408
- return textResult('No summary available (session may still be running or was not folded).');
409
- }
410
526
  const metaForRead = (() => {
411
527
  try { return JSON.parse(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8')); }
412
528
  catch { return {}; }
413
529
  })();
414
- const summaryText = fs.readFileSync(summaryPath, 'utf-8');
530
+ const summaryText = fs.existsSync(summaryPath)
531
+ ? fs.readFileSync(summaryPath, 'utf-8')
532
+ : '';
415
533
  const header = metaForRead.model ? `**Model:** ${metaForRead.model}\n\n` : '';
534
+ // A run that ended in a failed terminal status may have no usable summary:
535
+ // a crashed/timed-out run never writes summary.md, and a fast-failed
536
+ // shared-server run writes an EXISTING 0-byte summary.md. In both cases
537
+ // surface metadata.reason instead of a bare "No summary available" or an
538
+ // empty body (#36). Complete/partial-summary runs are unaffected.
539
+ if (FAILED_TERMINAL_STATUSES.includes(metaForRead.status) && !summaryText.trim()) {
540
+ const reason = metaForRead.reason || 'Unknown error';
541
+ return textResult(`${header}**Status:** ${metaForRead.status}\n**Reason:** ${reason}\n\n(No summary — the session ended in status '${metaForRead.status}'.)`);
542
+ }
543
+ if (!summaryText.trim()) {
544
+ return textResult('No summary available (session may still be running or was not folded).');
545
+ }
416
546
  return textResult(header + summaryText);
417
547
  },
418
548
 
@@ -495,6 +625,7 @@ const handlers = {
495
625
  try { spawnSidecarProcess(args, sessionDir); } catch (err) {
496
626
  return textResult(`Failed to continue: ${err.message}`, true);
497
627
  }
628
+ recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
498
629
  return textResult(JSON.stringify({
499
630
  taskId: newTaskId, status: 'running',
500
631
  message: 'Continuation started. Use amicus_status to check progress.',
@@ -504,7 +635,10 @@ const handlers = {
504
635
  async amicus_abort(input, project) {
505
636
  const cwd = project || getProjectDir(input.project);
506
637
  const metadata = readMetadata(input.taskId, cwd);
507
- if (!metadata) { return textResult(`Session ${input.taskId} not found.`, true); }
638
+ if (!metadata) {
639
+ return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
640
+ 'If you ran it in a different project, pass the original "project".', true);
641
+ }
508
642
  if (metadata.status !== 'running') {
509
643
  return textResult(`Session ${input.taskId} is not running (status: ${metadata.status}).`);
510
644
  }
@@ -576,6 +710,10 @@ const handlers = {
576
710
  taskId: waveId, type: 'wave', status: 'running', legs: legIds,
577
711
  models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
578
712
  }, null, 2), { mode: 0o600 });
713
+ // #40: index the wave AND each leg so status/read of any leg resolves the
714
+ // project even when the default later defaults to a different one.
715
+ recordSession(waveId, cwd);
716
+ for (const legId of legIds) { recordSession(legId, cwd); }
579
717
  } catch (err) {
580
718
  return textResult(`Failed to prepare fan-out wave: ${err.message}`, true);
581
719
  }
@@ -636,6 +774,15 @@ const handlers = {
636
774
  },
637
775
 
638
776
  async amicus_setup() {
777
+ const { checkElectronAvailable } = require('./sidecar/interactive');
778
+ if (!checkElectronAvailable()) {
779
+ return textResult(
780
+ 'The setup GUI cannot open because Electron is not installed, so no '
781
+ + 'window appeared. Run `amicus setup` in your terminal instead — it '
782
+ + 'falls back to a headless (readline) wizard for API key configuration.',
783
+ true
784
+ );
785
+ }
639
786
  try { spawnSidecarProcess(['setup']); } catch (err) {
640
787
  return textResult(`Failed to launch setup: ${err.message}`, true);
641
788
  }
@@ -662,14 +809,22 @@ const LEGACY_TOOL_ALIASES = {
662
809
  async function startMcpServer() {
663
810
  const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
664
811
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
665
- const server = new McpServer({ name: 'amicus', version: require('../package.json').version });
812
+ const server = new McpServer(
813
+ { name: 'amicus', version: require('../package.json').version },
814
+ // Declare the `roots` capability so the client advertises its roots and we
815
+ // can request them (roots/list) when no explicit project is supplied.
816
+ { capabilities: { roots: {} } }
817
+ );
666
818
 
667
819
  for (const tool of getTools()) {
668
820
  const register = (name) => server.registerTool(
669
821
  name,
670
822
  { description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations },
671
823
  async (input) => {
672
- try { return await handlers[tool.name](input, getProjectDir(input.project)); }
824
+ try {
825
+ const project = await resolveProjectDir(input.project, server);
826
+ return await handlers[tool.name](input, project);
827
+ }
673
828
  catch (err) {
674
829
  logger.error(`MCP tool error: ${name}`, { error: err.message });
675
830
  return textResult(`Error: ${err.message}`, true);
@@ -692,4 +847,7 @@ async function startMcpServer() {
692
847
  process.stderr.write('[amicus] MCP server running on stdio\n');
693
848
  }
694
849
 
695
- module.exports = { handlers, startMcpServer, getProjectDir, LEGACY_TOOL_ALIASES };
850
+ module.exports = {
851
+ handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
852
+ LEGACY_TOOL_ALIASES,
853
+ };
@@ -27,6 +27,40 @@ async function getCreateOpencodeServer() {
27
27
  return sdk.createOpencodeServer;
28
28
  }
29
29
 
30
+ /**
31
+ * Shared reason string for an HTTP 402 from the provider.
32
+ * Exported so #36 and a later pre-flight credit check can reuse the exact phrasing
33
+ * instead of re-deriving it.
34
+ */
35
+ const INSUFFICIENT_CREDITS_REASON = 'Insufficient credits';
36
+
37
+ /**
38
+ * Map an SDK request result to a propagated provider-failure reason.
39
+ *
40
+ * Keys on the actual HTTP status (result.response.status, falling back to
41
+ * result.error.status) so benign informational `error` payloads that ride on a
42
+ * 2xx response stay "continue to poll" — only a real non-2xx status becomes a
43
+ * session error.
44
+ *
45
+ * 402 -> 'Insufficient credits'
46
+ * other non-2xx -> 'Provider error: <status>'
47
+ * 2xx / unknown -> null
48
+ *
49
+ * @param {{response?: {status?: number}, error?: {status?: number}}} [result]
50
+ * @returns {string|null} Reason string, or null when there is no hard failure.
51
+ */
52
+ function providerErrorReason(result) {
53
+ if (!result) { return null; }
54
+ const status = (result.response && result.response.status)
55
+ || (result.error && result.error.status);
56
+ // No usable status — cannot distinguish a benign warning from a failure, so
57
+ // do not regress the "continue to poll" behavior.
58
+ if (typeof status !== 'number') { return null; }
59
+ if (status >= 200 && status < 300) { return null; }
60
+ if (status === 402) { return INSUFFICIENT_CREDITS_REASON; }
61
+ return `Provider error: ${status}`;
62
+ }
63
+
30
64
  /**
31
65
  * Parse a model string into SDK format
32
66
  *
@@ -73,15 +107,34 @@ async function createClient(baseUrl) {
73
107
  return createOpencodeClient(config);
74
108
  }
75
109
 
110
+ /**
111
+ * Build an optional `query: { directory }` fragment to spread into an SDK call.
112
+ *
113
+ * Returns an EMPTY object when no directory is supplied so spreading it is a
114
+ * true no-op — the emitted request is byte-for-byte identical to a call that
115
+ * never knew about `directory`. Only when a directory IS passed does a
116
+ * `query: { directory }` key appear on the wire (the SDK accepts
117
+ * `query?: { directory?: string }` on every session endpoint).
118
+ *
119
+ * @param {string} [directory] - Optional project directory to scope the call to.
120
+ * @returns {{query?: {directory: string}}} Fragment to spread into SDK args.
121
+ */
122
+ function directoryQuery(directory) {
123
+ return directory === undefined ? {} : { query: { directory } };
124
+ }
125
+
76
126
  /**
77
127
  * Create a new session
78
128
  *
79
129
  * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
130
+ * @param {string} [directory] - Optional project directory to scope the session
131
+ * to (threaded to the SDK as query.directory). Omitting it keeps the call
132
+ * byte-for-byte identical to before.
80
133
  * @returns {Promise<string>} Session ID
81
134
  * @throws {Error} If session creation fails
82
135
  */
83
- async function createSession(client) {
84
- const result = await client.session.create({});
136
+ async function createSession(client, directory) {
137
+ const result = await client.session.create({ ...directoryQuery(directory) });
85
138
 
86
139
  if (result.error) {
87
140
  throw new Error(result.error.message || 'Failed to create session');
@@ -111,10 +164,13 @@ async function createSession(client) {
111
164
  * @param {object} [options.reasoning] - Reasoning/thinking configuration
112
165
  * @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
113
166
  * @param {object} [options.watchdog] - IdleWatchdog instance to signal busy/idle around the API call
167
+ * @param {string} [options.directory] - Optional project directory to scope the
168
+ * call to (threaded to the SDK as query.directory). Omitting it keeps the
169
+ * call byte-for-byte identical to before.
114
170
  * @returns {Promise<object>} API response
115
171
  */
116
172
  async function sendPrompt(client, sessionId, options) {
117
- const { model, system, parts, agent, tools, reasoning, watchdog } = options;
173
+ const { model, system, parts, agent, tools, reasoning, watchdog, directory } = options;
118
174
 
119
175
  // Parse model string to SDK format
120
176
  const modelSpec = parseModelString(model);
@@ -151,7 +207,8 @@ async function sendPrompt(client, sessionId, options) {
151
207
  try {
152
208
  result = await client.session.promptAsync({
153
209
  path: { id: sessionId },
154
- body
210
+ body,
211
+ ...directoryQuery(directory)
155
212
  });
156
213
  } finally {
157
214
  if (watchdog) {
@@ -159,11 +216,25 @@ async function sendPrompt(client, sessionId, options) {
159
216
  }
160
217
  }
161
218
 
162
- // Log but don't throw on promptAsync errors.
163
- // promptAsync is fire-and-forget: the server queues the prompt for async
164
- // processing. Errors here may be informational (e.g., model config warnings)
165
- // rather than fatal. The polling loop will detect real failures via timeout.
166
- if (result.error) {
219
+ // Detect a hard provider failure at the client boundary (#37). A non-2xx /
220
+ // 402 here must surface as a session error EVEN WHEN the server emits no
221
+ // assistant message carrying info.error otherwise the run looks idle/empty.
222
+ // Keyed on HTTP status so benign informational errors (model config warnings
223
+ // on a 2xx) keep the fire-and-forget "continue to poll" behavior below.
224
+ const reason = providerErrorReason(result);
225
+ if (reason) {
226
+ result.providerError = reason;
227
+ const { logger } = require('./utils/logger');
228
+ logger.error('promptAsync returned a hard provider error', {
229
+ reason,
230
+ status: (result.response && result.response.status) || (result.error && result.error.status),
231
+ sessionId
232
+ });
233
+ } else if (result.error) {
234
+ // Log but don't throw on benign promptAsync errors.
235
+ // promptAsync is fire-and-forget: the server queues the prompt for async
236
+ // processing. These errors are informational (e.g., model config warnings)
237
+ // rather than fatal. The polling loop will detect real failures via timeout.
167
238
  const { logger } = require('./utils/logger');
168
239
  logger.error('promptAsync returned error (continuing to poll)', {
169
240
  error: result.error.message || JSON.stringify(result.error),
@@ -179,11 +250,15 @@ async function sendPrompt(client, sessionId, options) {
179
250
  *
180
251
  * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
181
252
  * @param {string} sessionId - Session ID
253
+ * @param {string} [directory] - Optional project directory to scope the call to
254
+ * (threaded to the SDK as query.directory). Omitting it keeps the call
255
+ * byte-for-byte identical to before.
182
256
  * @returns {Promise<Array>} Array of messages
183
257
  */
184
- async function getMessages(client, sessionId) {
258
+ async function getMessages(client, sessionId, directory) {
185
259
  const result = await client.session.messages({
186
- path: { id: sessionId }
260
+ path: { id: sessionId },
261
+ ...directoryQuery(directory)
187
262
  });
188
263
 
189
264
  return result.data || [];
@@ -266,10 +341,13 @@ async function listSessions(client) {
266
341
  *
267
342
  * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
268
343
  * @param {string} sessionId - Session ID to abort
344
+ * @param {string} [directory] - Optional project directory to scope the call to
345
+ * (threaded to the SDK as query.directory). Omitting it keeps the call
346
+ * byte-for-byte identical to before.
269
347
  * @returns {Promise<void>}
270
348
  */
271
- async function abortSession(client, sessionId) {
272
- await client.session.abort({ path: { id: sessionId } });
349
+ async function abortSession(client, sessionId, directory) {
350
+ await client.session.abort({ path: { id: sessionId }, ...directoryQuery(directory) });
273
351
  }
274
352
 
275
353
  /**
@@ -277,11 +355,15 @@ async function abortSession(client, sessionId) {
277
355
  *
278
356
  * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
279
357
  * @param {string} sessionId - Session ID
358
+ * @param {string} [directory] - Optional project directory to scope the call to
359
+ * (threaded to the SDK as query.directory). Omitting it keeps the call
360
+ * byte-for-byte identical to before.
280
361
  * @returns {Promise<Object>} Session status
281
362
  */
282
- async function getSessionStatus(client, sessionId) {
363
+ async function getSessionStatus(client, sessionId, directory) {
283
364
  const result = await client.session.status({
284
- path: { id: sessionId }
365
+ path: { id: sessionId },
366
+ ...directoryQuery(directory)
285
367
  });
286
368
 
287
369
  return result.data || {};
@@ -595,6 +677,8 @@ function parseMcpSpec(spec) {
595
677
  }
596
678
 
597
679
  module.exports = {
680
+ INSUFFICIENT_CREDITS_REASON,
681
+ providerErrorReason,
598
682
  parseModelString,
599
683
  createClient,
600
684
  createSession,
@@ -113,6 +113,11 @@ function createSession(projectDir, taskId, metadata) {
113
113
 
114
114
  // Create empty conversation.jsonl
115
115
  fs.writeFileSync(path.join(sessionDir, 'conversation.jsonl'), '', { mode: 0o600 });
116
+
117
+ // #40: record the taskId -> project mapping in the global index so a later
118
+ // lookup that defaults to a DIFFERENT project can still find this session.
119
+ // Best-effort: recordSession never throws.
120
+ require('./utils/session-index').recordSession(taskId, metadata.project || projectDir);
116
121
  }
117
122
 
118
123
  /**
@@ -200,8 +200,9 @@ async function continueSidecar(options) {
200
200
  const metaPath = SessionPaths.metadataFile(sessionDir);
201
201
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
202
202
 
203
- // Finalize session
204
- finalizeSession(sessionDir, summary, project, meta);
203
+ // Finalize session. Interactive mode legitimately returns an empty summary,
204
+ // so pass status explicitly to stay out of the #36 empty-summary guard.
205
+ finalizeSession(sessionDir, summary, project, meta, { status: 'complete' });
205
206
  }
206
207
 
207
208
  module.exports = {