amicus 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +59 -0
- package/electron/load-failsafe.js +11 -4
- package/electron/main.js +22 -13
- package/electron/opencode-theme.js +130 -0
- package/electron/preload.js +1 -1
- package/electron/session-route.js +28 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +59 -35
- package/src/cli-handlers-doctor.js +90 -14
- package/src/cli-handlers-run.js +4 -0
- package/src/cli.js +53 -0
- package/src/headless.js +21 -7
- package/src/mcp-server.js +167 -24
- package/src/mcp-tools.js +17 -1
- package/src/opencode-client.js +44 -10
- package/src/session-manager.js +5 -0
- package/src/sidecar/electron-cache.js +42 -0
- package/src/sidecar/electron-ensure.js +92 -0
- package/src/sidecar/electron-install.js +244 -0
- package/src/sidecar/fanout.js +3 -0
- package/src/sidecar/interactive.js +43 -17
- package/src/sidecar/setup-window.js +12 -7
- package/src/sidecar/setup.js +2 -0
- package/src/utils/project-path.js +61 -0
- package/src/utils/project-root-sanity.js +66 -0
- package/src/utils/remediation-hints.js +51 -0
- package/src/utils/result-schema.js +16 -3
- package/src/utils/session-index.js +98 -0
- package/src/utils/session-path.js +68 -0
- package/src/utils/validators.js +3 -27
- package/src/utils/version-info.js +49 -0
|
@@ -13,26 +13,33 @@ 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
const { canonicalProjectPath } = require('../utils/project-path');
|
|
17
|
+
const { ensureElectron } = require('./electron-ensure');
|
|
18
|
+
|
|
19
|
+
/** Resolve the Electron binary path ONLY when the exe actually exists on disk.
|
|
20
|
+
* #54: path.txt surviving (require('electron') resolving) is NOT enough — a
|
|
21
|
+
* quarantined/missing dist/<exe> must read as not-installed. Delegates to the
|
|
22
|
+
* stat-the-exe probe so the runtime check matches postinstall's strictness.
|
|
23
|
+
* Stays a PURE PROBE: no download/extract side-effect.
|
|
24
|
+
* @returns {string|null} Full path to a usable Electron binary, or null. */
|
|
20
25
|
function getElectronPath() {
|
|
21
26
|
try {
|
|
22
|
-
|
|
27
|
+
const { isElectronUsable, resolveElectronBinary } = require('./electron-install');
|
|
28
|
+
return isElectronUsable() ? resolveElectronBinary() : null;
|
|
23
29
|
} catch {
|
|
24
30
|
return null;
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
/** Check if Electron is available (lazy loading guard)
|
|
34
|
+
/** Check if Electron is available (lazy loading guard). Pure probe — stats the
|
|
35
|
+
* exe via getElectronPath(), never provisions. */
|
|
29
36
|
function checkElectronAvailable() {
|
|
30
37
|
return getElectronPath() !== null;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
/** Build environment variables for Electron process */
|
|
34
41
|
function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath, options = {}) {
|
|
35
|
-
const { agent, isResume, conversation, mcp, client, windowPosition } = options;
|
|
42
|
+
const { agent, isResume, conversation, mcp, client, windowPosition, sessionDirectory } = options;
|
|
36
43
|
const env = {
|
|
37
44
|
...process.env,
|
|
38
45
|
PATH: `${nodeModulesBin}${path.delimiter}${existingPath}`,
|
|
@@ -43,6 +50,10 @@ function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath,
|
|
|
43
50
|
|
|
44
51
|
if (client) { env.AMICUS_CLIENT = client; }
|
|
45
52
|
if (windowPosition) { env.AMICUS_WINDOW_POSITION = windowPosition; }
|
|
53
|
+
// The directory the OpenCode session is scoped to (#45). Electron builds the
|
|
54
|
+
// Web-UI route from THIS, not a fresh base64url(CWD) guess, so follow-up
|
|
55
|
+
// prompts resolve the session when process cwd != --cwd.
|
|
56
|
+
if (sessionDirectory) { env.AMICUS_SESSION_DIRECTORY = sessionDirectory; }
|
|
46
57
|
|
|
47
58
|
if (agent) {
|
|
48
59
|
const agentConfig = mapAgentToOpenCode(agent);
|
|
@@ -90,16 +101,28 @@ function handleElectronProcess(electronProcess, taskId, resolve) {
|
|
|
90
101
|
|
|
91
102
|
/** Run sidecar in interactive mode (Electron GUI) */
|
|
92
103
|
async function runInteractive(model, systemPrompt, userMessage, taskId, project, options = {}) {
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
// Lazily PROVISION electron on FIRST GUI use (#55). ensureElectron() is the
|
|
105
|
+
// one place network provisioning is allowed; the probes stay pure. When it
|
|
106
|
+
// returns ok:false the GUI is unavailable — fail gracefully toward --no-ui.
|
|
107
|
+
const ensured = await ensureElectron();
|
|
108
|
+
if (!ensured.ok) {
|
|
109
|
+
logger.error('Electron not available — interactive mode unavailable', { reason: ensured.reason });
|
|
95
110
|
return {
|
|
96
111
|
summary: '', completed: false, timedOut: false, taskId,
|
|
97
|
-
error:
|
|
112
|
+
error: `Interactive mode requires electron. ${ensured.reason} (or use --no-ui for headless mode)`
|
|
98
113
|
};
|
|
99
114
|
}
|
|
100
115
|
|
|
101
116
|
const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client } = options;
|
|
102
117
|
|
|
118
|
+
// Scope the OpenCode session to the project/--cwd (#45). The SDK Session
|
|
119
|
+
// object echoes a `directory`, but createSession() returns only the id, so we
|
|
120
|
+
// use the canonicalized --cwd CONSISTENTLY for BOTH the create scope and the
|
|
121
|
+
// Web-UI route — the documented fallback that keeps them matching even when
|
|
122
|
+
// the amicus process cwd != --cwd. undefined when project is falsy → calls
|
|
123
|
+
// stay byte-for-byte identical to before.
|
|
124
|
+
const sessionDirectory = canonicalProjectPath(project);
|
|
125
|
+
|
|
103
126
|
// Start OpenCode server with system prompt baked into agent config.
|
|
104
127
|
// Agent config prompts are hidden from the UI, unlike promptAsync's system field.
|
|
105
128
|
const agentConfig = mapAgentToOpenCode(agent);
|
|
@@ -126,14 +149,15 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
126
149
|
sessionId = opencodeSessionId;
|
|
127
150
|
logger.info('Reconnecting to existing session', { sessionId });
|
|
128
151
|
} else {
|
|
129
|
-
// New session: create and send initial prompt
|
|
130
|
-
sessionId = await createSession(ocClient);
|
|
152
|
+
// New session: create and send initial prompt, scoped to --cwd (#45).
|
|
153
|
+
sessionId = await createSession(ocClient, sessionDirectory);
|
|
131
154
|
|
|
132
155
|
// System prompt is set on agent config (hidden from UI).
|
|
133
156
|
// Do NOT pass system here — promptAsync's system field is visible in the UI.
|
|
134
157
|
const promptOptions = {
|
|
135
158
|
model,
|
|
136
|
-
parts: [{ type: 'text', text: userMessage }]
|
|
159
|
+
parts: [{ type: 'text', text: userMessage }],
|
|
160
|
+
directory: sessionDirectory
|
|
137
161
|
};
|
|
138
162
|
|
|
139
163
|
// Always set agent — defaults to 'chat' when not specified
|
|
@@ -171,26 +195,28 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
171
195
|
// Keep the idle clock from killing an actively-working session: poll OpenCode
|
|
172
196
|
// session status and touch the watchdog on any non-idle (busy/retry) state.
|
|
173
197
|
const activityPoller = createActivityPoller({
|
|
174
|
-
getStatus: () => getSessionStatus(ocClient, sessionId),
|
|
198
|
+
getStatus: () => getSessionStatus(ocClient, sessionId, sessionDirectory),
|
|
175
199
|
onActivity: () => watchdog.touch(),
|
|
176
200
|
});
|
|
177
201
|
|
|
178
202
|
const sessionDir = getSessionDir(project, taskId);
|
|
179
203
|
const mirror = startInteractiveMirror({
|
|
180
|
-
getMessages: () => getMessages(ocClient, sessionId),
|
|
204
|
+
getMessages: () => getMessages(ocClient, sessionId, sessionDirectory),
|
|
181
205
|
sessionDir,
|
|
182
206
|
onActivity: () => watchdog.touch(),
|
|
183
207
|
});
|
|
184
208
|
|
|
185
209
|
return new Promise((resolve, _reject) => {
|
|
186
|
-
|
|
210
|
+
// Prefer the path ensureElectron() resolved: a same-process first-use
|
|
211
|
+
// provision can leave require('electron') cached as a stale null (#55).
|
|
212
|
+
const electronPath = ensured.path || getElectronPath();
|
|
187
213
|
const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
|
|
188
214
|
|
|
189
215
|
const nodeModulesBin = path.join(__dirname, '..', '..', 'node_modules', '.bin');
|
|
190
216
|
const existingPath = process.env.PATH || '';
|
|
191
217
|
const env = buildElectronEnv(
|
|
192
218
|
taskId, model, project, nodeModulesBin, existingPath,
|
|
193
|
-
{ agent, isResume, conversation, mcp, client }
|
|
219
|
+
{ agent, isResume, conversation, mcp, client, sessionDirectory }
|
|
194
220
|
);
|
|
195
221
|
env.AMICUS_OPENCODE_PORT = serverPort;
|
|
196
222
|
env.AMICUS_SESSION_ID = sessionId;
|
|
@@ -10,19 +10,24 @@ const { spawn } = require('child_process');
|
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { logger } = require('../utils/logger');
|
|
12
12
|
const { getElectronPath } = require('./interactive');
|
|
13
|
+
const { ensureElectron } = require('./electron-ensure');
|
|
13
14
|
const { getCompatEnv } = require('../utils/env-compat');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* Launch the Electron setup window for API key entry
|
|
17
|
+
* Launch the Electron setup window for API key entry.
|
|
18
|
+
* Lazily PROVISIONS electron on first GUI use (#55) via ensureElectron() — the
|
|
19
|
+
* one place network provisioning is allowed; getElectronPath() stays a pure probe.
|
|
17
20
|
* @returns {Promise<{ success: boolean, error?: string }>}
|
|
18
21
|
*/
|
|
19
|
-
function launchSetupWindow() {
|
|
22
|
+
async function launchSetupWindow() {
|
|
23
|
+
const ensured = await ensureElectron();
|
|
24
|
+
if (!ensured.ok) {
|
|
25
|
+
return { success: false, error: ensured.reason || 'Electron not installed' };
|
|
26
|
+
}
|
|
20
27
|
return new Promise((resolve) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
28
|
+
// Prefer the path ensureElectron() resolved: a same-process first-use
|
|
29
|
+
// provision can leave require('electron') cached as a stale null (#55).
|
|
30
|
+
const electronPath = ensured.path || getElectronPath();
|
|
26
31
|
const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
|
|
27
32
|
|
|
28
33
|
const env = {
|
package/src/sidecar/setup.js
CHANGED
|
@@ -266,8 +266,10 @@ async function runReadlineSetup() {
|
|
|
266
266
|
if (foundKeys.length > 0) {
|
|
267
267
|
console.log(`API keys detected: ${foundKeys.join(', ')}`);
|
|
268
268
|
} else {
|
|
269
|
+
const { runDoctor } = require('../utils/remediation-hints');
|
|
269
270
|
console.log('No API keys detected.');
|
|
270
271
|
console.log('Set OPENROUTER_API_KEY to get started, or run: amicus setup');
|
|
272
|
+
console.log(`Not sure what's wrong? ${runDoctor}`);
|
|
271
273
|
}
|
|
272
274
|
console.log('');
|
|
273
275
|
|
|
@@ -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,66 @@
|
|
|
1
|
+
// src/utils/project-root-sanity.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Project-root sanity heuristic (#43).
|
|
6
|
+
*
|
|
7
|
+
* `amicus doctor` resolves the project dir from process.cwd(); when a user
|
|
8
|
+
* launches amicus from a packaged-app/install directory (e.g. the Claude
|
|
9
|
+
* Desktop app dir) instead of their repo, sessions land in the wrong place.
|
|
10
|
+
* This pure helper inspects a path string + its directory markers and decides
|
|
11
|
+
* whether to WARN. It never touches the filesystem and never throws.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Path fragments that signal a packaged-app / install directory rather than a
|
|
16
|
+
* user project. Matched case-insensitively against the normalized path.
|
|
17
|
+
*/
|
|
18
|
+
const INSTALL_PATTERNS = [
|
|
19
|
+
/anthropicclaude/i, // Claude Desktop app dir
|
|
20
|
+
/[\\/]app-\d/i, // versioned electron app dir, e.g. app-1.2.3
|
|
21
|
+
/appdata[\\/]local/i, // Windows per-user app data
|
|
22
|
+
/program files/i, // Windows install root
|
|
23
|
+
/[\\/]\.asar/i, // packaged electron resources
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** @returns {boolean} true if the path looks like an app/install dir */
|
|
27
|
+
function looksLikeInstallDir(dir) {
|
|
28
|
+
if (!dir || typeof dir !== 'string') { return false; }
|
|
29
|
+
return INSTALL_PATTERNS.some((re) => re.test(dir));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Assess a resolved project dir.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} dir Resolved project directory (e.g. process.cwd()).
|
|
36
|
+
* @param {{hasGit?:boolean, hasPackageJson?:boolean, hasClaude?:boolean}} markers
|
|
37
|
+
* Presence of .git / package.json / .claude in `dir`.
|
|
38
|
+
* @returns {{status:'ok'|'warn', message:string, hint:string|null}}
|
|
39
|
+
*/
|
|
40
|
+
function assessProjectRoot(dir, markers) {
|
|
41
|
+
const m = markers || {};
|
|
42
|
+
const safeDir = (dir && typeof dir === 'string') ? dir : '';
|
|
43
|
+
const hint =
|
|
44
|
+
'pass an explicit project (amicus … --project <path>) or cd into your repo before running amicus';
|
|
45
|
+
|
|
46
|
+
if (looksLikeInstallDir(safeDir)) {
|
|
47
|
+
return {
|
|
48
|
+
status: 'warn',
|
|
49
|
+
message: `${safeDir || '(empty)'} looks like an app/install dir, not a project`,
|
|
50
|
+
hint,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hasMarker = !!(m.hasGit || m.hasPackageJson || m.hasClaude);
|
|
55
|
+
if (!hasMarker) {
|
|
56
|
+
return {
|
|
57
|
+
status: 'warn',
|
|
58
|
+
message: `${safeDir || '(empty)'} has no project markers (.git / package.json / .claude)`,
|
|
59
|
+
hint,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { status: 'ok', message: safeDir, hint: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { assessProjectRoot, looksLikeInstallDir, INSTALL_PATTERNS };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/utils/remediation-hints.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared, copy-paste remediation hint strings.
|
|
6
|
+
*
|
|
7
|
+
* One source of truth for the fix commands surfaced by `amicus doctor` and by
|
|
8
|
+
* failure messages across the CLI, so the guidance never drifts. Consumed by
|
|
9
|
+
* src/cli-handlers-doctor.js (per-check hints) and reused by sweep'd failure
|
|
10
|
+
* sites (#32) and the MCP recovery surface (#43).
|
|
11
|
+
*
|
|
12
|
+
* The object is frozen — these are a stable copy-paste contract; callers read
|
|
13
|
+
* fields, they do not mutate them.
|
|
14
|
+
*/
|
|
15
|
+
const REMEDIATION_HINTS = Object.freeze({
|
|
16
|
+
/** Canonical global (re)install. */
|
|
17
|
+
reinstall: 'npm install -g amicus',
|
|
18
|
+
|
|
19
|
+
/** `npm cache clean --force` — clears a corrupt npm cache before reinstalling. */
|
|
20
|
+
cacheClean: 'npm cache clean --force',
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Engine binaries missing/rolled back. A transient install error can roll
|
|
24
|
+
* back the platform engine packages; re-run, or clean the cache and reinstall.
|
|
25
|
+
*/
|
|
26
|
+
reinstallEngine:
|
|
27
|
+
'npm install -g amicus (a transient install error can roll back the engine binaries — re-run, or: npm cache clean --force && npm install -g amicus)',
|
|
28
|
+
|
|
29
|
+
/** Electron absent — reinstall to add the interactive GUI (headless still works). */
|
|
30
|
+
reinstallElectron: 'npm install -g amicus (reinstall to add Electron)',
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Electron present but broken (ABI mismatch / partial unpack). Delete the
|
|
34
|
+
* vendored copy and reinstall to force a clean rebuild.
|
|
35
|
+
*/
|
|
36
|
+
rebuildElectron:
|
|
37
|
+
'rm -rf node_modules/electron && npm install -g amicus (rebuild Electron after an ABI mismatch or partial unpack)',
|
|
38
|
+
|
|
39
|
+
/** Point the user at the single recovery hub. */
|
|
40
|
+
runDoctor: 'run: amicus doctor (diagnoses config, keys, engine & MCP, with copy-paste fixes)',
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Self-heal the optional Electron GUI in place (#56). This is the convergence
|
|
44
|
+
* target for the three "reinstall to fix Electron" hints — it provisions the
|
|
45
|
+
* binary from cache (or downloads on demand) WITHOUT a global reinstall, so it
|
|
46
|
+
* can't loop the way `npm install -g amicus` could when the rollback recurs.
|
|
47
|
+
*/
|
|
48
|
+
doctorFix: 'amicus doctor --fix (self-heal the Electron GUI in place — provisions the binary; no reinstall, so it can\'t loop)',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
module.exports = REMEDIATION_HINTS;
|
|
@@ -106,9 +106,19 @@ function waveExitCode(waveStatus) {
|
|
|
106
106
|
* @param {object} opts
|
|
107
107
|
* @param {string} opts.waveId
|
|
108
108
|
* @param {Array<object>} opts.legs - run documents (in --models order)
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
*
|
|
110
|
+
* COUNTS REMAINDER RULE (stable, no schemaVersion bump): `counts` exposes four
|
|
111
|
+
* NAMED terminal buckets — complete, error, timeout, aborted — plus `total`
|
|
112
|
+
* (= legs.length). The remaining TERMINAL_STATUSES ('crashed', 'idle-timeout')
|
|
113
|
+
* and any non-terminal status (e.g. 'running' in a live-rebuilt wave) are
|
|
114
|
+
* deliberately NOT given their own bucket; they are reflected ONLY in `total`.
|
|
115
|
+
* Therefore a consumer must treat the unnamed remainder as
|
|
116
|
+
* total − (complete + error + timeout + aborted)
|
|
117
|
+
* and must NOT assume the named buckets sum to `total`. Adding new buckets
|
|
118
|
+
* would change the document shape and REQUIRES bumping SCHEMA_VERSION.
|
|
119
|
+
* This agrees with the MCP wave path (mcp-server.js), which counts a leg as
|
|
120
|
+
* "done" iff its status is in TERMINAL_STATUSES — including 'crashed' — so a
|
|
121
|
+
* crashed leg is done/total there exactly as it is total-only here.
|
|
112
122
|
* @param {{source: string, file: string|null, chars: number}|null} [opts.promptMeta]
|
|
113
123
|
* @param {string|null} [opts.createdAt]
|
|
114
124
|
* @param {string|null} [opts.completedAt]
|
|
@@ -117,6 +127,9 @@ function waveExitCode(waveStatus) {
|
|
|
117
127
|
*/
|
|
118
128
|
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
|
|
119
129
|
const { sumWaveUsage } = require('./pricing');
|
|
130
|
+
// Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
|
|
131
|
+
// 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
|
|
132
|
+
// only, so total may exceed complete+error+timeout+aborted.
|
|
120
133
|
const counts = {
|
|
121
134
|
total: legs.length,
|
|
122
135
|
complete: legs.filter(l => l.status === 'complete').length,
|
|
@@ -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
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/version-info — running vs. on-disk amicus version (#33)
|
|
3
|
+
*
|
|
4
|
+
* After an `npm i -g amicus` upgrade, a long-lived MCP server process keeps
|
|
5
|
+
* running the OLD code until the client restarts it. That staleness is
|
|
6
|
+
* invisible from inside an agent session. These helpers surface the running
|
|
7
|
+
* version in MCP responses and, via a CALL-TIME re-read of the on-disk
|
|
8
|
+
* package.json, flag when the two have diverged.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
/** Absolute path to this install's package.json (repo root). */
|
|
14
|
+
const PKG_PATH = path.join(__dirname, '..', '..', 'package.json');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The version baked into the running process at load time. Free: package.json
|
|
18
|
+
* is already require()'d elsewhere, so this hits the module cache.
|
|
19
|
+
*/
|
|
20
|
+
const RUNNING_VERSION = require('../../package.json').version;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Re-read the on-disk package.json version at call time. Wrapped in try/catch
|
|
24
|
+
* — the file may be mid-rewrite, missing, or unreadable during/after an
|
|
25
|
+
* upgrade — and returns null on any failure rather than throwing.
|
|
26
|
+
* @returns {string|null}
|
|
27
|
+
*/
|
|
28
|
+
function readOnDiskVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
|
|
31
|
+
return (pkg && typeof pkg.version === 'string') ? pkg.version : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One-line staleness warning, or null when there's nothing to warn about
|
|
39
|
+
* (on-disk version unreadable or identical to the running version).
|
|
40
|
+
* @returns {string|null}
|
|
41
|
+
*/
|
|
42
|
+
function versionWarning() {
|
|
43
|
+
const onDisk = readOnDiskVersion();
|
|
44
|
+
if (!onDisk || onDisk === RUNNING_VERSION) { return null; }
|
|
45
|
+
return `Amicus was upgraded on disk (running v${RUNNING_VERSION}, on-disk v${onDisk}). `
|
|
46
|
+
+ `Restart your MCP client to load v${onDisk}.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { RUNNING_VERSION, readOnDiskVersion, versionWarning, PKG_PATH };
|