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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +67 -0
- package/README.md +40 -11
- package/bin/amicus.js +3 -2
- package/electron/main.js +10 -4
- package/electron/session-route.js +28 -0
- package/package.json +4 -3
- package/scripts/postinstall.js +103 -3
- package/scripts/setup-hooks.js +7 -3
- package/src/cli-handlers-doctor.js +1 -1
- package/src/cli.js +95 -8
- package/src/headless.js +43 -8
- package/src/mcp-server.js +178 -20
- package/src/opencode-client.js +99 -15
- package/src/session-manager.js +5 -0
- package/src/sidecar/continue.js +3 -2
- package/src/sidecar/interactive.js +21 -7
- package/src/sidecar/resume.js +4 -2
- package/src/sidecar/session-finalize.js +41 -1
- package/src/sidecar/session-utils.js +8 -2
- package/src/sidecar/setup.js +32 -0
- package/src/utils/api-key-validation.js +72 -0
- package/src/utils/project-path.js +61 -0
- package/src/utils/result-schema.js +1 -0
- package/src/utils/session-index.js +98 -0
- package/src/utils/session-path.js +68 -0
- package/src/utils/validators.js +3 -27
|
@@ -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;
|
package/src/sidecar/resume.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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
|
|
package/src/sidecar/setup.js
CHANGED
|
@@ -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 };
|
|
@@ -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 };
|
package/src/utils/validators.js
CHANGED
|
@@ -45,33 +45,9 @@ function validateTaskId(taskId) {
|
|
|
45
45
|
return { valid: true };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|