amicus 1.6.0 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": { "name": "Christian Wagner" },
6
6
  "homepage": "https://bourbondog.github.io/amicus/",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.6.1] - 2026-06-30
9
+
10
+ Project-directory and session-addressing correctness — agents, sessions, and the interactive GUI now agree on which project they're in.
11
+
12
+ ### Added
13
+ - **`AMICUS_PROJECT_DIR` + MCP `roots` support.** When the project is not passed explicitly, the MCP
14
+ server now resolves the working directory from the client's first `file://` workspace root (falling
15
+ back to `AMICUS_PROJECT_DIR`, then the process cwd) — so a stdio MCP server spawned by a desktop
16
+ client no longer roots agents in the app install directory where they can't see your files.
17
+ - **Global session index.** `amicus_status` / `amicus_read` / `amicus_list` now consult a global
18
+ `taskId -> project` index on a per-project miss, so a session created in one project is still found
19
+ when looked up from another.
20
+ - **Per-command help for the rest of the CLI.** `amicus council --help` (and `continue`, `resume`,
21
+ `doctor`, `setup`, `key`, `mcp`) now print their own scoped usage instead of the full global help.
22
+
23
+ ### Fixed
24
+ - **Interactive `--cwd`: follow-up prompts no longer fail "unable to retrieve session."** When the
25
+ launch directory differs from `--cwd` (the normal sidecar-skill pattern), the OpenCode session is now
26
+ scoped to the project directory and the Electron Web-UI route is built from the **server-echoed**
27
+ session directory rather than a guessed one, so turn 2+ resolve correctly.
28
+ - **Shared-server MCP sessions are scoped to the project directory** — every create and follow-up call
29
+ carries the directory, so headless MCP sessions are found on a server shared across projects.
30
+ - **`amicus_read` surfaces the failure reason** for crashed / timed-out / aborted runs that wrote no
31
+ summary, instead of a bare "No summary available."
32
+ - **`amicus_abort`'s "session not found"** now names the resolved project, matching `status` / `read`.
33
+ - Internal: a single `canonicalProjectPath()` now normalizes project paths (slash direction, drive-letter
34
+ case, trailing slash, UNC shares) so creation and lookup always agree.
35
+
8
36
  ## [1.6.0] - 2026-06-30
9
37
 
10
38
  Install resilience and council-failure correctness — the first two blocks of the post-1.5 backlog program.
package/electron/main.js CHANGED
@@ -21,6 +21,7 @@ const { createFoldHandler } = require('./fold');
21
21
  const { registerSetupHandlers } = require('./ipc-setup');
22
22
  const { computeWindowPosition } = require('./window-position');
23
23
  const { attachLoadFailsafe, buildLoadErrorHTML } = require('./load-failsafe');
24
+ const { buildSessionRoute } = require('./session-route');
24
25
 
25
26
  const ICON_PATH = path.join(__dirname, 'assets', 'icon.png');
26
27
 
@@ -51,6 +52,11 @@ const MODE = getCompatEnv('MODE') || 'sidecar';
51
52
  const TASK_ID = getCompatEnv('TASK_ID') || 'unknown';
52
53
  const MODEL = getCompatEnv('MODEL') || 'unknown';
53
54
  const CWD = getCompatEnv('CWD') || process.cwd();
55
+ // The directory the OpenCode session was actually scoped to (#45). Set by the
56
+ // interactive launcher as canonicalProjectPath(--cwd) so the Web-UI route is
57
+ // built from the SAME directory createSession used. Falls back to CWD for
58
+ // back-compat with launchers that predate this env var.
59
+ const SESSION_DIRECTORY = getCompatEnv('SESSION_DIRECTORY') || CWD;
54
60
  const CLIENT = getCompatEnv('CLIENT') || 'code-local';
55
61
  const OPENCODE_PORT = parseInt(getCompatEnv('OPENCODE_PORT') || '4096', 10);
56
62
  const OPENCODE_SESSION_ID = getCompatEnv('SESSION_ID');
@@ -152,10 +158,10 @@ function createAmicusWindow() {
152
158
  });
153
159
 
154
160
  // Navigate directly to the session URL to bypass the project selection screen.
155
- // OpenCode's router format: /<base64url(projectPath)>/session/<sessionId>
156
- const contentUrl = OPENCODE_SESSION_ID
157
- ? `${OPENCODE_URL}/${Buffer.from(CWD).toString('base64url')}/session/${OPENCODE_SESSION_ID}`
158
- : OPENCODE_URL;
161
+ // Build the route from SESSION_DIRECTORY — the directory the session was
162
+ // actually scoped to — NOT a fresh base64url(CWD) guess, so Web-UI follow-up
163
+ // prompts resolve the session even when process cwd != --cwd (#45).
164
+ const contentUrl = buildSessionRoute(OPENCODE_URL, OPENCODE_SESSION_ID, SESSION_DIRECTORY);
159
165
 
160
166
  // The window only becomes visible on the success path below. Without this
161
167
  // failsafe, a failed/stalled UI load leaves an invisible window and a
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Web-UI session route builder (#45).
3
+ *
4
+ * OpenCode's router format is `/<base64url(projectPath)>/session/<sessionId>`.
5
+ * The route MUST be built from the directory the OpenCode session was actually
6
+ * created/scoped to — NOT a fresh base64url(process.cwd()) guess. When the
7
+ * amicus process cwd != --cwd (the normal sidecar-skill launch), a CWD-derived
8
+ * route points at a project route with no matching session, so Web-UI follow-up
9
+ * prompts fail "unable to retrieve session". The caller passes the same
10
+ * directory it used to scope createSession (the server-echoed session.directory,
11
+ * or a consistent canonicalProjectPath(--cwd) fallback).
12
+ *
13
+ * Pure function: no electron, no fs, no process state. Safe to unit-test.
14
+ *
15
+ * @param {string} baseUrl - OpenCode server base URL (e.g. http://localhost:4096)
16
+ * @param {string} [sessionId] - OpenCode session id; falsy → return baseUrl only
17
+ * @param {string} sessionDirectory - The directory the session is scoped to
18
+ * @returns {string} Fully-qualified route URL, or baseUrl when no session id.
19
+ */
20
+ function buildSessionRoute(baseUrl, sessionId, sessionDirectory) {
21
+ if (!sessionId) {
22
+ return baseUrl;
23
+ }
24
+ const seg = Buffer.from(sessionDirectory).toString('base64url');
25
+ return `${baseUrl}/${seg}/session/${sessionId}`;
26
+ }
27
+
28
+ module.exports = { buildSessionRoute };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -404,6 +404,56 @@ Options for 'read':
404
404
  --conversation Show full conversation
405
405
  --metadata Show session metadata
406
406
  --json Emit the run/wave result as stable JSON
407
+ `,
408
+ continue: `
409
+ Options for 'continue':
410
+ <task_id> Required. Session to build on (positional)
411
+ --prompt <text> Required. Briefing for the new session
412
+ --model <model> Optional. Override the model (alias or provider/model)
413
+ --cwd <path> Project directory (default: cwd)
414
+ --no-ui Run without GUI (autonomous mode)
415
+ --timeout <minutes> Headless timeout (default: 15)
416
+ --context-turns <N> Max conversation turns (default: 50)
417
+ --context-max-tokens <N> Max context tokens (default: 80000)
418
+ `,
419
+ resume: `
420
+ Options for 'resume':
421
+ <task_id> Required. Session to reopen (positional)
422
+ --cwd <path> Project directory (default: cwd)
423
+ --no-ui Run without GUI (autonomous mode)
424
+ --timeout <minutes> Headless timeout (default: 15)
425
+ `,
426
+ council: `
427
+ Subcommands for 'council':
428
+ tally <input.json> Tally findings → tiers/street-cred (appends to ledger)
429
+ --no-ledger Compute the record without appending to the ledger
430
+ --json Machine-readable output
431
+ stats Reviewer-reliability aggregates from the ledger
432
+ --json Machine-readable output
433
+ report <verdict.json> Disagreement + verdict report
434
+ --wave <wave.json> Include per-leg run stats from a wave file
435
+ --md Emit Markdown (default)
436
+ --html Emit a self-contained HTML page
437
+ `,
438
+ doctor: `
439
+ Options for 'doctor':
440
+ --json Machine-readable output
441
+ `,
442
+ setup: `
443
+ Options for 'setup':
444
+ (no args) Run the interactive setup wizard
445
+ --api-keys Open the API key setup window
446
+ --add-alias <name=model> Add a model alias without the full wizard
447
+ `,
448
+ key: `
449
+ Usage for 'key':
450
+ key <provider> <apikey> Validate and save a key
451
+ key <provider> --remove Remove a saved key
452
+ key List all configured providers
453
+ `,
454
+ mcp: `
455
+ Usage for 'mcp':
456
+ mcp Start the MCP server (stdio transport)
407
457
  `
408
458
  };
409
459
 
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,9 @@ 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');
14
17
 
15
18
  /**
16
19
  * Elapsed run duration: time between createdAt and the run's end, bounding the
@@ -24,15 +27,104 @@ function elapsedMs(metadata) {
24
27
  return durationBetween(metadata.createdAt, end) ?? 0;
25
28
  }
26
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'];
38
+
27
39
  const sharedServer = new SharedServerManager({ logger });
28
40
 
29
- /** 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
+ */
30
53
  function getProjectDir(explicitProject) {
31
- 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
+ }
32
61
  const cwd = process.cwd();
33
- if (cwd !== '/' && fs.existsSync(cwd)) { return cwd; }
62
+ if (cwd !== '/' && fs.existsSync(cwd)) { return canonicalProjectPath(cwd); }
34
63
  if (cwd === '/') { logger.warn('cwd is root (/), falling back to $HOME'); }
35
- 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);
36
128
  }
37
129
 
38
130
  /** Read session metadata from disk, or null if not found */
@@ -140,10 +232,16 @@ const handlers = {
140
232
  const { finalizeHeadlessResult } = require('./sidecar/session-finalize');
141
233
  // resolvedModel is already available from validateStartInputs() above
142
234
 
143
- 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);
144
241
 
145
242
  // Write initial metadata (MCP handler owns this, runHeadless skips it)
146
243
  fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
244
+ recordSession(taskId, cwd); // #40: global index for cross-project lookup
147
245
  const metaPath = path.join(sessionDir, 'metadata.json');
148
246
  const serverPort = server.url ? new URL(server.url).port : null;
149
247
  fs.writeFileSync(metaPath, JSON.stringify({
@@ -195,6 +293,7 @@ const handlers = {
195
293
  runHeadless(resolvedModel, systemPrompt, userMessage, taskId, cwd,
196
294
  timeoutMs, agent, {
197
295
  client, server, watchdog, sessionId,
296
+ directory: cwd, // #47: scope every per-session follow-up call to the project
198
297
  mcp: undefined, // shared server already has MCP config
199
298
  }
200
299
  ).then((result) => {
@@ -246,6 +345,7 @@ const handlers = {
246
345
 
247
346
  if (child && child.pid) {
248
347
  fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
348
+ recordSession(taskId, cwd); // #40: global index for cross-project lookup
249
349
  const metaPath = path.join(sessionDir, 'metadata.json');
250
350
  if (!fs.existsSync(metaPath)) {
251
351
  fs.writeFileSync(metaPath, JSON.stringify({
@@ -423,21 +523,25 @@ const handlers = {
423
523
  }
424
524
  // Default: summary
425
525
  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
526
  const metaForRead = (() => {
430
527
  try { return JSON.parse(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8')); }
431
528
  catch { return {}; }
432
529
  })();
433
- const summaryText = fs.readFileSync(summaryPath, 'utf-8');
530
+ const summaryText = fs.existsSync(summaryPath)
531
+ ? fs.readFileSync(summaryPath, 'utf-8')
532
+ : '';
434
533
  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()) {
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()) {
439
540
  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.)`);
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).');
441
545
  }
442
546
  return textResult(header + summaryText);
443
547
  },
@@ -521,6 +625,7 @@ const handlers = {
521
625
  try { spawnSidecarProcess(args, sessionDir); } catch (err) {
522
626
  return textResult(`Failed to continue: ${err.message}`, true);
523
627
  }
628
+ recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
524
629
  return textResult(JSON.stringify({
525
630
  taskId: newTaskId, status: 'running',
526
631
  message: 'Continuation started. Use amicus_status to check progress.',
@@ -530,7 +635,10 @@ const handlers = {
530
635
  async amicus_abort(input, project) {
531
636
  const cwd = project || getProjectDir(input.project);
532
637
  const metadata = readMetadata(input.taskId, cwd);
533
- 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
+ }
534
642
  if (metadata.status !== 'running') {
535
643
  return textResult(`Session ${input.taskId} is not running (status: ${metadata.status}).`);
536
644
  }
@@ -602,6 +710,10 @@ const handlers = {
602
710
  taskId: waveId, type: 'wave', status: 'running', legs: legIds,
603
711
  models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
604
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); }
605
717
  } catch (err) {
606
718
  return textResult(`Failed to prepare fan-out wave: ${err.message}`, true);
607
719
  }
@@ -697,14 +809,22 @@ const LEGACY_TOOL_ALIASES = {
697
809
  async function startMcpServer() {
698
810
  const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
699
811
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
700
- 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
+ );
701
818
 
702
819
  for (const tool of getTools()) {
703
820
  const register = (name) => server.registerTool(
704
821
  name,
705
822
  { description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations },
706
823
  async (input) => {
707
- 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
+ }
708
828
  catch (err) {
709
829
  logger.error(`MCP tool error: ${name}`, { error: err.message });
710
830
  return textResult(`Error: ${err.message}`, true);
@@ -727,4 +847,7 @@ async function startMcpServer() {
727
847
  process.stderr.write('[amicus] MCP server running on stdio\n');
728
848
  }
729
849
 
730
- module.exports = { handlers, startMcpServer, getProjectDir, LEGACY_TOOL_ALIASES };
850
+ module.exports = {
851
+ handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
852
+ LEGACY_TOOL_ALIASES,
853
+ };
@@ -107,15 +107,34 @@ async function createClient(baseUrl) {
107
107
  return createOpencodeClient(config);
108
108
  }
109
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
+
110
126
  /**
111
127
  * Create a new session
112
128
  *
113
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.
114
133
  * @returns {Promise<string>} Session ID
115
134
  * @throws {Error} If session creation fails
116
135
  */
117
- async function createSession(client) {
118
- const result = await client.session.create({});
136
+ async function createSession(client, directory) {
137
+ const result = await client.session.create({ ...directoryQuery(directory) });
119
138
 
120
139
  if (result.error) {
121
140
  throw new Error(result.error.message || 'Failed to create session');
@@ -145,10 +164,13 @@ async function createSession(client) {
145
164
  * @param {object} [options.reasoning] - Reasoning/thinking configuration
146
165
  * @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
147
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.
148
170
  * @returns {Promise<object>} API response
149
171
  */
150
172
  async function sendPrompt(client, sessionId, options) {
151
- const { model, system, parts, agent, tools, reasoning, watchdog } = options;
173
+ const { model, system, parts, agent, tools, reasoning, watchdog, directory } = options;
152
174
 
153
175
  // Parse model string to SDK format
154
176
  const modelSpec = parseModelString(model);
@@ -185,7 +207,8 @@ async function sendPrompt(client, sessionId, options) {
185
207
  try {
186
208
  result = await client.session.promptAsync({
187
209
  path: { id: sessionId },
188
- body
210
+ body,
211
+ ...directoryQuery(directory)
189
212
  });
190
213
  } finally {
191
214
  if (watchdog) {
@@ -227,11 +250,15 @@ async function sendPrompt(client, sessionId, options) {
227
250
  *
228
251
  * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
229
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.
230
256
  * @returns {Promise<Array>} Array of messages
231
257
  */
232
- async function getMessages(client, sessionId) {
258
+ async function getMessages(client, sessionId, directory) {
233
259
  const result = await client.session.messages({
234
- path: { id: sessionId }
260
+ path: { id: sessionId },
261
+ ...directoryQuery(directory)
235
262
  });
236
263
 
237
264
  return result.data || [];
@@ -314,10 +341,13 @@ async function listSessions(client) {
314
341
  *
315
342
  * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
316
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.
317
347
  * @returns {Promise<void>}
318
348
  */
319
- async function abortSession(client, sessionId) {
320
- await client.session.abort({ path: { id: sessionId } });
349
+ async function abortSession(client, sessionId, directory) {
350
+ await client.session.abort({ path: { id: sessionId }, ...directoryQuery(directory) });
321
351
  }
322
352
 
323
353
  /**
@@ -325,11 +355,15 @@ async function abortSession(client, sessionId) {
325
355
  *
326
356
  * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
327
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.
328
361
  * @returns {Promise<Object>} Session status
329
362
  */
330
- async function getSessionStatus(client, sessionId) {
363
+ async function getSessionStatus(client, sessionId, directory) {
331
364
  const result = await client.session.status({
332
- path: { id: sessionId }
365
+ path: { id: sessionId },
366
+ ...directoryQuery(directory)
333
367
  });
334
368
 
335
369
  return result.data || {};
@@ -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
  /**
@@ -13,6 +13,7 @@ const { logger } = require('../utils/logger');
13
13
  const { getCompatEnv } = require('../utils/env-compat');
14
14
  const { startInteractiveMirror } = require('./interactive-mirror');
15
15
  const { getSessionDir } = require('../session-manager');
16
+ const { canonicalProjectPath } = require('../utils/project-path');
16
17
 
17
18
  /** Get the Electron binary path via require('electron').
18
19
  * Works in all install contexts (global, local, npx hoisted).
@@ -32,7 +33,7 @@ function checkElectronAvailable() {
32
33
 
33
34
  /** Build environment variables for Electron process */
34
35
  function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath, options = {}) {
35
- const { agent, isResume, conversation, mcp, client, windowPosition } = options;
36
+ const { agent, isResume, conversation, mcp, client, windowPosition, sessionDirectory } = options;
36
37
  const env = {
37
38
  ...process.env,
38
39
  PATH: `${nodeModulesBin}${path.delimiter}${existingPath}`,
@@ -43,6 +44,10 @@ function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath,
43
44
 
44
45
  if (client) { env.AMICUS_CLIENT = client; }
45
46
  if (windowPosition) { env.AMICUS_WINDOW_POSITION = windowPosition; }
47
+ // The directory the OpenCode session is scoped to (#45). Electron builds the
48
+ // Web-UI route from THIS, not a fresh base64url(CWD) guess, so follow-up
49
+ // prompts resolve the session when process cwd != --cwd.
50
+ if (sessionDirectory) { env.AMICUS_SESSION_DIRECTORY = sessionDirectory; }
46
51
 
47
52
  if (agent) {
48
53
  const agentConfig = mapAgentToOpenCode(agent);
@@ -100,6 +105,14 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
100
105
 
101
106
  const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client } = options;
102
107
 
108
+ // Scope the OpenCode session to the project/--cwd (#45). The SDK Session
109
+ // object echoes a `directory`, but createSession() returns only the id, so we
110
+ // use the canonicalized --cwd CONSISTENTLY for BOTH the create scope and the
111
+ // Web-UI route — the documented fallback that keeps them matching even when
112
+ // the amicus process cwd != --cwd. undefined when project is falsy → calls
113
+ // stay byte-for-byte identical to before.
114
+ const sessionDirectory = canonicalProjectPath(project);
115
+
103
116
  // Start OpenCode server with system prompt baked into agent config.
104
117
  // Agent config prompts are hidden from the UI, unlike promptAsync's system field.
105
118
  const agentConfig = mapAgentToOpenCode(agent);
@@ -126,14 +139,15 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
126
139
  sessionId = opencodeSessionId;
127
140
  logger.info('Reconnecting to existing session', { sessionId });
128
141
  } else {
129
- // New session: create and send initial prompt
130
- sessionId = await createSession(ocClient);
142
+ // New session: create and send initial prompt, scoped to --cwd (#45).
143
+ sessionId = await createSession(ocClient, sessionDirectory);
131
144
 
132
145
  // System prompt is set on agent config (hidden from UI).
133
146
  // Do NOT pass system here — promptAsync's system field is visible in the UI.
134
147
  const promptOptions = {
135
148
  model,
136
- parts: [{ type: 'text', text: userMessage }]
149
+ parts: [{ type: 'text', text: userMessage }],
150
+ directory: sessionDirectory
137
151
  };
138
152
 
139
153
  // Always set agent — defaults to 'chat' when not specified
@@ -171,13 +185,13 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
171
185
  // Keep the idle clock from killing an actively-working session: poll OpenCode
172
186
  // session status and touch the watchdog on any non-idle (busy/retry) state.
173
187
  const activityPoller = createActivityPoller({
174
- getStatus: () => getSessionStatus(ocClient, sessionId),
188
+ getStatus: () => getSessionStatus(ocClient, sessionId, sessionDirectory),
175
189
  onActivity: () => watchdog.touch(),
176
190
  });
177
191
 
178
192
  const sessionDir = getSessionDir(project, taskId);
179
193
  const mirror = startInteractiveMirror({
180
- getMessages: () => getMessages(ocClient, sessionId),
194
+ getMessages: () => getMessages(ocClient, sessionId, sessionDirectory),
181
195
  sessionDir,
182
196
  onActivity: () => watchdog.touch(),
183
197
  });
@@ -190,7 +204,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
190
204
  const existingPath = process.env.PATH || '';
191
205
  const env = buildElectronEnv(
192
206
  taskId, model, project, nodeModulesBin, existingPath,
193
- { agent, isResume, conversation, mcp, client }
207
+ { agent, isResume, conversation, mcp, client, sessionDirectory }
194
208
  );
195
209
  env.AMICUS_OPENCODE_PORT = serverPort;
196
210
  env.AMICUS_SESSION_ID = sessionId;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Canonical project-path helper.
3
+ *
4
+ * A project/cwd path can be written many incidental ways that all refer to the
5
+ * SAME logical directory. If a path used to CREATE a session differs (by any of
6
+ * these) from the path used to LOOK IT UP or build a route, the two won't match
7
+ * and the session/route is lost. This module collapses those incidental
8
+ * differences to ONE canonical string.
9
+ *
10
+ * Canonical form (documented contract):
11
+ * - forward slashes ('/'), never backslashes
12
+ * - duplicate/mixed separators collapsed to a single '/'
13
+ * - a leading UNC double-slash is preserved ('\\\\server\\share' -> '//server/share')
14
+ * so a network share never collapses into (and collides with) a local path
15
+ * - Windows drive letter upper-cased (c: -> C:)
16
+ * - no trailing slash, EXCEPT a bare root is preserved ('C:/' and '/')
17
+ *
18
+ * Pure function: no fs, no network, no process state. Safe to call anywhere.
19
+ */
20
+
21
+ /**
22
+ * Normalize a project/cwd path to its canonical string form.
23
+ *
24
+ * @param {string} p - A filesystem path (Windows or POSIX style).
25
+ * @returns {string} The canonical path, or the input unchanged when falsy.
26
+ */
27
+ function canonicalProjectPath(p) {
28
+ // Preserve falsy input (''/undefined/null) so callers can pass through an
29
+ // absent directory without special-casing.
30
+ if (!p) {
31
+ return p;
32
+ }
33
+
34
+ // 1. Backslashes -> forward slashes.
35
+ let out = p.replace(/\\/g, '/');
36
+ // A leading UNC double-slash (\\server\share) must survive the separator
37
+ // collapse below — flattening it to a single '/' would turn a network share
38
+ // into a local-looking path that could collide with one. Remember it, collapse
39
+ // duplicate/mixed separators, then restore the UNC prefix.
40
+ const isUnc = out.startsWith('//');
41
+ out = out.replace(/\/+/g, '/');
42
+ if (isUnc) {
43
+ out = `/${out}`;
44
+ }
45
+
46
+ // 2. Upper-case a leading Windows drive letter (c: -> C:).
47
+ out = out.replace(/^([a-z]):/, (_m, d) => `${d.toUpperCase()}:`);
48
+
49
+ // 3. Strip a trailing slash, but keep a bare root ('C:/' or '/').
50
+ if (out.length > 1 && out.endsWith('/')) {
51
+ const withoutSlash = out.slice(0, -1);
52
+ // 'C:/' -> withoutSlash is 'C:' (a bare drive); keep the root slash.
53
+ if (!/^[A-Z]:$/.test(withoutSlash)) {
54
+ out = withoutSlash;
55
+ }
56
+ }
57
+
58
+ return out;
59
+ }
60
+
61
+ module.exports = { canonicalProjectPath };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Global session index (issue #40).
3
+ *
4
+ * The on-disk session store is PROJECT-scoped:
5
+ * <project>/.claude/amicus_sessions/<taskId>/
6
+ * but the project a lookup resolves to can differ from the one a session was
7
+ * created under (a stdio MCP server defaults to its install-dir cwd, MCP roots
8
+ * may be absent, etc.). When that happens a perfectly valid session reads back
9
+ * as "not found".
10
+ *
11
+ * This module maintains ONE global index, under the config dir, mapping
12
+ * taskId -> canonicalProjectPath(project)
13
+ * written at session START and consulted ONLY on a per-project miss. It is a
14
+ * navigation aid, never authoritative: a corrupt/partial index must NEVER crash
15
+ * a lookup, so every read is guarded and degrades to "no entry".
16
+ *
17
+ * Concurrency: parallel fan-out/council runs write here at once. Writes are
18
+ * atomic via write-temp + rename (rename is atomic on a single filesystem), and
19
+ * a unique temp name per write avoids two writers colliding on the temp file.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+ const { getConfigDir } = require('./config');
26
+ const { canonicalProjectPath } = require('./project-path');
27
+
28
+ /** Index filename under the config dir. */
29
+ const INDEX_FILENAME = 'sessions-index.json';
30
+
31
+ /** @returns {string} Absolute path to the index file. */
32
+ function indexPath() {
33
+ return path.join(getConfigDir(), INDEX_FILENAME);
34
+ }
35
+
36
+ /**
37
+ * Read the index, returning a plain object. Never throws: a missing,
38
+ * unreadable, or corrupt file yields an empty map.
39
+ * @returns {Record<string, string>}
40
+ */
41
+ function readIndex() {
42
+ try {
43
+ const raw = fs.readFileSync(indexPath(), 'utf-8');
44
+ const parsed = JSON.parse(raw);
45
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
46
+ return parsed;
47
+ }
48
+ } catch {
49
+ // Missing / unreadable / corrupt — fall through to a fresh map.
50
+ }
51
+ return {};
52
+ }
53
+
54
+ /**
55
+ * Record taskId -> canonical project path at session START.
56
+ *
57
+ * Best-effort and crash-safe: a failure here must never block starting a
58
+ * session, and a pre-existing corrupt index is recovered to a fresh map rather
59
+ * than propagated. The write is atomic (temp file + rename).
60
+ *
61
+ * @param {string} taskId
62
+ * @param {string} project - any spelling of the project dir; canonicalized here.
63
+ */
64
+ function recordSession(taskId, project) {
65
+ if (!taskId || !project) { return; }
66
+ const canonical = canonicalProjectPath(project);
67
+ try {
68
+ const dir = getConfigDir();
69
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
70
+ const index = readIndex(); // already guarded; corrupt -> {}
71
+ index[taskId] = canonical;
72
+ const target = path.join(dir, INDEX_FILENAME);
73
+ // Unique temp name so concurrent writers never clobber the same temp file.
74
+ const tmp = path.join(dir, `.${INDEX_FILENAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
75
+ fs.writeFileSync(tmp, JSON.stringify(index, null, 2), { mode: 0o600 });
76
+ fs.renameSync(tmp, target); // atomic on a single filesystem
77
+ } catch {
78
+ // Index is a navigation aid; never let its failure break a session start.
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Look up the canonical project path a taskId was created under.
84
+ * @param {string} taskId
85
+ * @returns {string|null} canonical project path, or null if unknown.
86
+ */
87
+ function lookupSessionProject(taskId) {
88
+ if (!taskId) { return null; }
89
+ const index = readIndex(); // guarded; never throws
90
+ const project = index[taskId];
91
+ return (typeof project === 'string' && project) ? project : null;
92
+ }
93
+
94
+ module.exports = {
95
+ INDEX_FILENAME,
96
+ recordSession,
97
+ lookupSessionProject,
98
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Session path resolution.
3
+ *
4
+ * Resolves the on-disk directory for a session taskId under a project, with the
5
+ * path-traversal guard, the legacy sidecar_sessions shim, and (issue #40) a
6
+ * cross-project fallback via the global session index when the session is not
7
+ * found under the project this lookup defaulted to.
8
+ *
9
+ * Extracted from validators.js to keep that module under the size gate.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('../session-manager');
15
+ const { lookupSessionProject } = require('./session-index');
16
+ const { canonicalProjectPath } = require('./project-path');
17
+
18
+ /** Resolve a session path under a single root, throwing on path traversal. */
19
+ function safeSessionDirUnder(project, root, taskId) {
20
+ // Resolve so both sides share the same drive/separator form; on Windows path.join
21
+ // yields a driveless root (\tmp\...) while path.resolve(taskId) adds the drive.
22
+ const sessionsDir = path.resolve(path.join(project, '.claude', root));
23
+ const resolved = path.resolve(sessionsDir, taskId);
24
+ if (!resolved.startsWith(sessionsDir + path.sep)) {
25
+ throw new Error('Invalid task ID: path traversal detected');
26
+ }
27
+ return resolved;
28
+ }
29
+
30
+ /** Probe both roots (canonical, then legacy) under one project; null if neither exists. */
31
+ function existingDirUnderProject(project, taskId) {
32
+ const canonical = safeSessionDirUnder(project, SESSIONS_DIR, taskId);
33
+ if (fs.existsSync(canonical)) { return canonical; }
34
+ const legacy = safeSessionDirUnder(project, LEGACY_SESSIONS_DIR, taskId);
35
+ if (fs.existsSync(legacy)) { return legacy; }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Resolve an EXISTING session path: prefer canonical amicus, fall back to the
41
+ * legacy sidecar_sessions dir (shim). The traversal guard runs against BOTH
42
+ * roots, so a malicious taskId is rejected regardless of root.
43
+ *
44
+ * On a per-project MISS (#40), consult the global index for the project the
45
+ * taskId was actually recorded under and probe there — so a session created
46
+ * under project A is still found when the lookup defaulted to project B. The
47
+ * index read is guarded (corrupt index → null), the indexed project is
48
+ * re-validated through the same traversal guard, and an index hit is only
49
+ * returned when the dir actually exists; otherwise fall back to the canonical
50
+ * (writeable) path under the given project.
51
+ *
52
+ * @throws {Error} If resolved path escapes the sessions directory
53
+ */
54
+ function safeSessionDir(project, taskId) {
55
+ const local = existingDirUnderProject(project, taskId);
56
+ if (local) { return local; }
57
+
58
+ const indexedProject = lookupSessionProject(taskId);
59
+ if (indexedProject && canonicalProjectPath(indexedProject) !== canonicalProjectPath(project)) {
60
+ const indexed = existingDirUnderProject(indexedProject, taskId);
61
+ if (indexed) { return indexed; }
62
+ }
63
+
64
+ // Default to the canonical (writeable) path under the given project.
65
+ return safeSessionDirUnder(project, SESSIONS_DIR, taskId);
66
+ }
67
+
68
+ module.exports = { safeSessionDir, safeSessionDirUnder };
@@ -45,33 +45,9 @@ function validateTaskId(taskId) {
45
45
  return { valid: true };
46
46
  }
47
47
 
48
- const { SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('../session-manager');
49
-
50
- /** Resolve a session path under a single root, throwing on path traversal. */
51
- function safeSessionDirUnder(project, root, taskId) {
52
- // Resolve so both sides share the same drive/separator form; on Windows path.join
53
- // yields a driveless root (\tmp\...) while path.resolve(taskId) adds the drive.
54
- const sessionsDir = path.resolve(path.join(project, '.claude', root));
55
- const resolved = path.resolve(sessionsDir, taskId);
56
- if (!resolved.startsWith(sessionsDir + path.sep)) {
57
- throw new Error('Invalid task ID: path traversal detected');
58
- }
59
- return resolved;
60
- }
61
-
62
- /**
63
- * Resolve an EXISTING session path: prefer canonical amicus, fall back to the
64
- * legacy sidecar_sessions dir (shim). The traversal guard runs against BOTH
65
- * roots, so a malicious taskId is rejected regardless of root.
66
- * @throws {Error} If resolved path escapes the sessions directory
67
- */
68
- function safeSessionDir(project, taskId) {
69
- const canonical = safeSessionDirUnder(project, SESSIONS_DIR, taskId);
70
- if (fs.existsSync(canonical)) { return canonical; }
71
- const legacy = safeSessionDirUnder(project, LEGACY_SESSIONS_DIR, taskId);
72
- if (fs.existsSync(legacy)) { return legacy; }
73
- return canonical;
74
- }
48
+ // Session path resolution (incl. #40 cross-project index fallback) lives in its
49
+ // own module to keep this file under the size gate; re-exported below.
50
+ const { safeSessionDir } = require('./session-path');
75
51
 
76
52
  /**
77
53
  * Validate prompt content is not empty or whitespace-only