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.
@@ -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;
@@ -202,8 +202,10 @@ async function resumeSidecar(options) {
202
202
  // Output summary
203
203
  outputSummary(summary);
204
204
 
205
- // Finalize session (use updatedMetadata which has resumedAt)
206
- finalizeSession(sessionDir, summary, project, updatedMetadata);
205
+ // Finalize session (use updatedMetadata which has resumedAt). Pass status
206
+ // explicitly to preserve the pre-#36 default ('complete') and stay out of
207
+ // the empty-summary guard — interactive resume legitimately has no summary.
208
+ finalizeSession(sessionDir, summary, project, updatedMetadata, { status: 'complete' });
207
209
  } finally {
208
210
  if (heartbeat) { heartbeat.stop(); }
209
211
  releaseLock(sessionDir);
@@ -23,4 +23,44 @@ function resolveTerminalState(result, signal) {
23
23
  return { status: 'error', exitCode: 1 };
24
24
  }
25
25
 
26
- module.exports = { resolveTerminalState };
26
+ /**
27
+ * Finalize a headless run by routing through resolveTerminalState — the single
28
+ * source of truth shared with the CLI start.js path. An errored run writes
29
+ * status='error' + reason (and an EXISTING 0-byte summary.md so amicus_read hits
30
+ * its file-exists branch); every other state persists the (possibly partial)
31
+ * summary with the correct terminal status. Never defaults a failed run to
32
+ * 'complete'. Used by the shared-server MCP .then handler (#36).
33
+ *
34
+ * @param {string} sessionDir
35
+ * @param {{completed?:boolean,timedOut?:boolean,aborted?:boolean,error?:any,summary?:string}|null} result
36
+ * @param {string} project
37
+ * @param {object} metadata - mutated + persisted to metadata.json
38
+ */
39
+ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
40
+ // Lazy require to avoid a circular dependency (session-utils requires nothing
41
+ // here, but keep symmetry with start.js which also lazy-requires).
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+ const { finalizeSession, SessionPaths } = require('./session-utils');
45
+
46
+ const terminal = resolveTerminalState(result);
47
+ if (terminal.status === 'error') {
48
+ // Write an existing (0-byte) summary so amicus_read hits the file-exists
49
+ // branch and surfaces metadata.reason rather than "No summary available".
50
+ fs.writeFileSync(SessionPaths.summaryFile(sessionDir), result && result.summary ? result.summary : '', { mode: 0o600 });
51
+ metadata.status = 'error';
52
+ metadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
53
+ metadata.completedAt = new Date().toISOString();
54
+ fs.writeFileSync(
55
+ path.join(sessionDir, 'metadata.json'),
56
+ JSON.stringify(metadata, null, 2),
57
+ { mode: 0o600 }
58
+ );
59
+ return;
60
+ }
61
+ // complete / timed-out / aborted: persist the (possibly partial) summary with
62
+ // the resolved status. Explicit status means the #36 guard won't re-classify.
63
+ finalizeSession(sessionDir, (result && result.summary) || '', project, metadata, { status: terminal.status });
64
+ }
65
+
66
+ module.exports = { resolveTerminalState, finalizeHeadlessResult };
@@ -90,8 +90,14 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
90
90
  // Save summary
91
91
  fs.writeFileSync(SessionPaths.summaryFile(sessionDir), summary, { mode: 0o600 });
92
92
 
93
- // Update metadata to the resolved terminal status (default complete).
94
- metadata.status = opts.status || 'complete';
93
+ // Update metadata to the resolved terminal status. Callers that know the
94
+ // terminal state (CLI start.js, shared-server finalizeHeadlessResult) pass it
95
+ // explicitly via opts.status — that always wins, so they are never
96
+ // re-classified. Defense-in-depth (#36): when NO status is supplied, an empty
97
+ // summary must never silently default to 'complete' — that hid errored/empty
98
+ // shared-server runs behind a 0-byte summary and a false success.
99
+ const hasSummary = typeof summary === 'string' && summary.trim().length > 0;
100
+ metadata.status = opts.status || (hasSummary ? 'complete' : 'error');
95
101
  metadata.completedAt = new Date().toISOString();
96
102
  fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
97
103
 
@@ -70,6 +70,31 @@ function detectApiKeys() {
70
70
  return readApiKeys();
71
71
  }
72
72
 
73
+ /**
74
+ * #38 — Non-blocking OpenRouter credit warning. Reads the OpenRouter key
75
+ * value, calls checkOpenRouterCredit, and prints a WARNING (never blocks) when
76
+ * the key is zero-credit or free tier. Any failure is swallowed silently — a
77
+ * credit probe must never stop setup from completing.
78
+ */
79
+ /* eslint-disable no-console -- CLI wizard requires direct console output */
80
+ async function warnOnLowOpenRouterCredit() {
81
+ try {
82
+ const { readApiKeyValues } = require('../utils/api-key-store');
83
+ const { checkOpenRouterCredit } = require('../utils/api-key-validation');
84
+ const values = readApiKeyValues();
85
+ const key = values && values.openrouter;
86
+ if (!key) { return; }
87
+ const { warning } = await checkOpenRouterCredit(key);
88
+ if (warning) {
89
+ console.log(`Warning: ${warning}`);
90
+ console.log('');
91
+ }
92
+ } catch (err) {
93
+ logger.debug('OpenRouter credit check skipped', { error: err.message });
94
+ }
95
+ }
96
+ /* eslint-enable no-console */
97
+
73
98
  /**
74
99
  * Prompt the user with a question via readline
75
100
  * @param {readline.Interface} rl - Readline interface
@@ -246,6 +271,12 @@ async function runReadlineSetup() {
246
271
  }
247
272
  console.log('');
248
273
 
274
+ // #38 — non-blocking zero-credit / free-tier OpenRouter warning. Never
275
+ // blocks: free-tier councils against free models are legitimate.
276
+ if (keys.openrouter) {
277
+ await warnOnLowOpenRouterCredit();
278
+ }
279
+
249
280
  const mode = await askQuestion(rl,
250
281
  'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
251
282
  if (mode === '2') {
@@ -410,4 +441,5 @@ module.exports = {
410
441
  runApiKeySetup,
411
442
  seedCatalog,
412
443
  seedFreeCouncil,
444
+ warnOnLowOpenRouterCredit,
413
445
  };
@@ -87,11 +87,83 @@ function validateApiKey(provider, key) {
87
87
  });
88
88
  }
89
89
 
90
+ /** Warning string for a zero-credit OpenRouter key (paid models will 402). */
91
+ const OPENROUTER_NO_CREDIT_WARNING =
92
+ 'OpenRouter key has no remaining credit — paid models will fail (402). ' +
93
+ 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).';
94
+
95
+ /** Warning string for a free-tier OpenRouter key. */
96
+ const OPENROUTER_FREE_TIER_WARNING =
97
+ 'OpenRouter key is free tier — only :free models will route; paid models will fail (402). ' +
98
+ 'Add credit at openrouter.ai/credits to use paid models.';
99
+
100
+ /**
101
+ * Non-blocking credit/limit check for an OpenRouter key.
102
+ *
103
+ * Hits GET https://openrouter.ai/api/v1/key (returns limit, usage,
104
+ * is_free_tier, limit_remaining) and produces a WARNING — never an error —
105
+ * when is_free_tier is true or limit_remaining <= 0. Any failure (non-200,
106
+ * network error, malformed body) resolves with warning:null so setup is
107
+ * never blocked. Free-tier councils against free models are legitimate.
108
+ *
109
+ * @param {string} key OpenRouter API key
110
+ * @returns {Promise<{warning: string|null, isFreeTier: boolean,
111
+ * limitRemaining: number|null, limit: number|null, usage: number|null}>}
112
+ */
113
+ function checkOpenRouterCredit(key) {
114
+ const none = {
115
+ warning: null, isFreeTier: false, limitRemaining: null, limit: null, usage: null
116
+ };
117
+ if (!key || key.trim().length === 0) {
118
+ return Promise.resolve(none);
119
+ }
120
+
121
+ const headers = { 'Authorization': `Bearer ${key.trim()}` };
122
+
123
+ return new Promise((resolve) => {
124
+ const req = https.get('https://openrouter.ai/api/v1/key', { headers }, (res) => {
125
+ let body = '';
126
+ res.on('data', (chunk) => { body += chunk; });
127
+ res.on('end', () => {
128
+ if (res.statusCode !== 200) { resolve(none); return; }
129
+ let data;
130
+ try {
131
+ data = (JSON.parse(body) || {}).data || {};
132
+ } catch (_e) {
133
+ resolve(none);
134
+ return;
135
+ }
136
+ const isFreeTier = data.is_free_tier === true;
137
+ const limitRemaining = (typeof data.limit_remaining === 'number')
138
+ ? data.limit_remaining : null;
139
+ const limit = (typeof data.limit === 'number') ? data.limit : null;
140
+ const usage = (typeof data.usage === 'number') ? data.usage : null;
141
+
142
+ let warning = null;
143
+ if (limitRemaining !== null && limitRemaining <= 0) {
144
+ warning = OPENROUTER_NO_CREDIT_WARNING;
145
+ } else if (isFreeTier) {
146
+ warning = OPENROUTER_FREE_TIER_WARNING;
147
+ }
148
+ resolve({ warning, isFreeTier, limitRemaining, limit, usage });
149
+ });
150
+ });
151
+ req.setTimeout(10000, () => {
152
+ req.destroy();
153
+ resolve(none);
154
+ });
155
+ req.on('error', () => { resolve(none); });
156
+ });
157
+ }
158
+
90
159
  // Backwards compat alias
91
160
  const validateOpenRouterKey = validateApiKey;
92
161
 
93
162
  module.exports = {
94
163
  validateApiKey,
95
164
  validateOpenRouterKey,
165
+ checkOpenRouterCredit,
166
+ OPENROUTER_NO_CREDIT_WARNING,
167
+ OPENROUTER_FREE_TIER_WARNING,
96
168
  VALIDATION_ENDPOINTS
97
169
  };
@@ -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 };
@@ -267,6 +267,7 @@ function buildDoctorDoc({ version, timestamp, checks }) {
267
267
  module.exports = {
268
268
  SCHEMA_VERSION,
269
269
  TERMINAL_STATUSES,
270
+ durationBetween,
270
271
  statusFromResult,
271
272
  buildRunResult,
272
273
  buildWaveResult,
@@ -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