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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +28 -0
- package/electron/main.js +10 -4
- package/electron/session-route.js +28 -0
- package/package.json +1 -1
- package/src/cli.js +50 -0
- package/src/headless.js +21 -7
- package/src/mcp-server.js +141 -18
- package/src/opencode-client.js +44 -10
- package/src/session-manager.js +5 -0
- package/src/sidecar/interactive.js +21 -7
- package/src/utils/project-path.js +61 -0
- package/src/utils/session-index.js +98 -0
- package/src/utils/session-path.js +68 -0
- package/src/utils/validators.js +3 -27
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.6.
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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)) {
|
|
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
|
-
|
|
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.
|
|
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
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
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
|
|
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) {
|
|
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(
|
|
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 {
|
|
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 = {
|
|
850
|
+
module.exports = {
|
|
851
|
+
handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
|
|
852
|
+
LEGACY_TOOL_ALIASES,
|
|
853
|
+
};
|
package/src/opencode-client.js
CHANGED
|
@@ -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 || {};
|
package/src/session-manager.js
CHANGED
|
@@ -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 };
|
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
|