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
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');
|
|
@@ -289,7 +303,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
289
303
|
agent: promptOptions.agent,
|
|
290
304
|
userMessageLength: userMessage.length
|
|
291
305
|
});
|
|
292
|
-
await sendPromptAsync(client, sessionId, promptOptions);
|
|
306
|
+
const promptResult = await sendPromptAsync(client, sessionId, promptOptions);
|
|
293
307
|
writeProgress(sessionDir, 'prompt_sent');
|
|
294
308
|
logger.info('Prompt sent successfully, entering polling loop', {
|
|
295
309
|
sessionId,
|
|
@@ -302,6 +316,16 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
302
316
|
let aborted = false;
|
|
303
317
|
let sessionError = null; // Captures model/SDK errors from assistant messages
|
|
304
318
|
|
|
319
|
+
// Hard provider failure detected at the client boundary (#37): a non-2xx /
|
|
320
|
+
// 402 from promptAsync surfaces here even when the server never emits an
|
|
321
|
+
// assistant message carrying info.error. Seed sessionError so the loop's
|
|
322
|
+
// "error with no output" gate ends the run promptly with a usable reason.
|
|
323
|
+
const boundaryProviderError = !!(promptResult && promptResult.providerError);
|
|
324
|
+
if (boundaryProviderError) {
|
|
325
|
+
sessionError = promptResult.providerError;
|
|
326
|
+
logger.error('Provider error at client boundary', { taskId, sessionId, reason: sessionError });
|
|
327
|
+
}
|
|
328
|
+
|
|
305
329
|
// Poll for completion by checking messages
|
|
306
330
|
const startTime = Date.now();
|
|
307
331
|
const deadline = startTime + timeoutMs;
|
|
@@ -334,7 +358,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
334
358
|
logger.info('External abort signal received', { taskId });
|
|
335
359
|
try {
|
|
336
360
|
const { abortSession } = require('./opencode-client');
|
|
337
|
-
await abortSession(client, sessionId);
|
|
361
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
338
362
|
} catch (abortErr) {
|
|
339
363
|
logger.warn('Failed to abort OpenCode session', { error: abortErr.message });
|
|
340
364
|
}
|
|
@@ -350,7 +374,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
350
374
|
try {
|
|
351
375
|
const remaining = deadline - Date.now();
|
|
352
376
|
const messages = await withTimeout(
|
|
353
|
-
getMessages(client, sessionId),
|
|
377
|
+
getMessages(client, sessionId, ...dirArgs),
|
|
354
378
|
Math.min(pollCallTimeoutMs, remaining),
|
|
355
379
|
'getMessages'
|
|
356
380
|
);
|
|
@@ -383,6 +407,17 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
383
407
|
break;
|
|
384
408
|
}
|
|
385
409
|
|
|
410
|
+
// Hard provider failure at the client boundary (#37): the request was
|
|
411
|
+
// rejected (e.g., 402) so no assistant message will ever arrive. Exit as
|
|
412
|
+
// soon as we confirm nothing streamed — don't wait for assistantFinished
|
|
413
|
+
// (which never flips here) or the full timeout.
|
|
414
|
+
if (boundaryProviderError && !mirror.output) {
|
|
415
|
+
logger.error('Provider error at boundary with no output, exiting', {
|
|
416
|
+
sessionError, pollCount
|
|
417
|
+
});
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
|
|
386
421
|
// If the model returned an error with no output, exit immediately
|
|
387
422
|
// (don't wait for timeout — the model won't produce anything)
|
|
388
423
|
if (sessionError && !mirror.output && assistantFinished) {
|
|
@@ -399,7 +434,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
399
434
|
try {
|
|
400
435
|
const remainingForStatus = deadline - Date.now();
|
|
401
436
|
const statusData = await withTimeout(
|
|
402
|
-
getSessionStatus(client, sessionId),
|
|
437
|
+
getSessionStatus(client, sessionId, ...dirArgs),
|
|
403
438
|
Math.min(pollCallTimeoutMs, remainingForStatus),
|
|
404
439
|
'getSessionStatus'
|
|
405
440
|
);
|
|
@@ -488,7 +523,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
488
523
|
// Abort the OpenCode session on timeout (agent keeps running otherwise)
|
|
489
524
|
try {
|
|
490
525
|
const { abortSession } = require('./opencode-client');
|
|
491
|
-
await abortSession(client, sessionId);
|
|
526
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
492
527
|
logger.info('Session aborted after timeout', { taskId, sessionId });
|
|
493
528
|
} catch (abortErr) {
|
|
494
529
|
logger.warn('Failed to abort session after timeout', { error: abortErr.message });
|
|
@@ -551,7 +586,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
551
586
|
if (sessionId) {
|
|
552
587
|
try {
|
|
553
588
|
const { abortSession } = require('./opencode-client');
|
|
554
|
-
await abortSession(client, sessionId);
|
|
589
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
555
590
|
} catch {
|
|
556
591
|
// Ignore abort errors during error handling
|
|
557
592
|
}
|
package/src/mcp-server.js
CHANGED
|
@@ -10,16 +10,121 @@ const { safeSessionDir } = require('./utils/validators');
|
|
|
10
10
|
const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
|
|
11
11
|
const { readProgress, isStalled } = require('./sidecar/progress');
|
|
12
12
|
const { SharedServerManager } = require('./utils/shared-server');
|
|
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');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Elapsed run duration: time between createdAt and the run's end, bounding the
|
|
20
|
+
* end by completedAt/abortedAt/crashedAt so a delayed poll of a finished run
|
|
21
|
+
* reports the run duration, not time-since-start. Falls back to now() while
|
|
22
|
+
* still running. Returns 0 if createdAt is missing/malformed.
|
|
23
|
+
*/
|
|
24
|
+
function elapsedMs(metadata) {
|
|
25
|
+
const end = metadata.completedAt || metadata.abortedAt || metadata.crashedAt
|
|
26
|
+
|| new Date().toISOString();
|
|
27
|
+
return durationBetween(metadata.createdAt, end) ?? 0;
|
|
28
|
+
}
|
|
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'];
|
|
13
38
|
|
|
14
39
|
const sharedServer = new SharedServerManager({ logger });
|
|
15
40
|
|
|
16
|
-
/**
|
|
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
|
+
*/
|
|
17
53
|
function getProjectDir(explicitProject) {
|
|
18
|
-
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
|
+
}
|
|
19
61
|
const cwd = process.cwd();
|
|
20
|
-
if (cwd !== '/' && fs.existsSync(cwd)) { return cwd; }
|
|
62
|
+
if (cwd !== '/' && fs.existsSync(cwd)) { return canonicalProjectPath(cwd); }
|
|
21
63
|
if (cwd === '/') { logger.warn('cwd is root (/), falling back to $HOME'); }
|
|
22
|
-
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);
|
|
23
128
|
}
|
|
24
129
|
|
|
25
130
|
/** Read session metadata from disk, or null if not found */
|
|
@@ -124,13 +229,19 @@ const handlers = {
|
|
|
124
229
|
const { buildContext } = require('./sidecar/context-builder');
|
|
125
230
|
const { buildPrompts } = require('./prompt-builder');
|
|
126
231
|
const { runHeadless } = require('./headless');
|
|
127
|
-
const {
|
|
232
|
+
const { finalizeHeadlessResult } = require('./sidecar/session-finalize');
|
|
128
233
|
// resolvedModel is already available from validateStartInputs() above
|
|
129
234
|
|
|
130
|
-
|
|
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);
|
|
131
241
|
|
|
132
242
|
// Write initial metadata (MCP handler owns this, runHeadless skips it)
|
|
133
243
|
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
244
|
+
recordSession(taskId, cwd); // #40: global index for cross-project lookup
|
|
134
245
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
135
246
|
const serverPort = server.url ? new URL(server.url).port : null;
|
|
136
247
|
fs.writeFileSync(metaPath, JSON.stringify({
|
|
@@ -182,13 +293,16 @@ const handlers = {
|
|
|
182
293
|
runHeadless(resolvedModel, systemPrompt, userMessage, taskId, cwd,
|
|
183
294
|
timeoutMs, agent, {
|
|
184
295
|
client, server, watchdog, sessionId,
|
|
296
|
+
directory: cwd, // #47: scope every per-session follow-up call to the project
|
|
185
297
|
mcp: undefined, // shared server already has MCP config
|
|
186
298
|
}
|
|
187
299
|
).then((result) => {
|
|
188
|
-
// Session
|
|
300
|
+
// Session done — route through resolveTerminalState (same single source
|
|
301
|
+
// of truth as the CLI start.js path) so an errored/timed-out/aborted run
|
|
302
|
+
// can never silently default to 'complete' with a 0-byte summary (#36).
|
|
189
303
|
try {
|
|
190
304
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
191
|
-
|
|
305
|
+
finalizeHeadlessResult(sessionDir, result, cwd, meta);
|
|
192
306
|
} catch (finErr) {
|
|
193
307
|
logger.warn('Failed to finalize session', { error: finErr.message });
|
|
194
308
|
}
|
|
@@ -231,6 +345,7 @@ const handlers = {
|
|
|
231
345
|
|
|
232
346
|
if (child && child.pid) {
|
|
233
347
|
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
348
|
+
recordSession(taskId, cwd); // #40: global index for cross-project lookup
|
|
234
349
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
235
350
|
if (!fs.existsSync(metaPath)) {
|
|
236
351
|
fs.writeFileSync(metaPath, JSON.stringify({
|
|
@@ -259,7 +374,10 @@ const handlers = {
|
|
|
259
374
|
const cwd = project || getProjectDir(input.project);
|
|
260
375
|
const sessionDir = safeSessionDir(cwd, input.taskId);
|
|
261
376
|
const metadata = readMetadata(input.taskId, cwd);
|
|
262
|
-
if (!metadata) {
|
|
377
|
+
if (!metadata) {
|
|
378
|
+
return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
|
|
379
|
+
'If you ran it in a different project, pass the original "project".', true);
|
|
380
|
+
}
|
|
263
381
|
|
|
264
382
|
if (metadata.type === 'wave') {
|
|
265
383
|
const legs = (metadata.legs || []).map((legId) => {
|
|
@@ -306,7 +424,7 @@ const handlers = {
|
|
|
306
424
|
}
|
|
307
425
|
}
|
|
308
426
|
|
|
309
|
-
const ms =
|
|
427
|
+
const ms = elapsedMs(metadata);
|
|
310
428
|
const response = {
|
|
311
429
|
taskId: metadata.taskId, type: 'wave', status: metadata.status,
|
|
312
430
|
legsComplete: done, legsTotal: legs.length, legs,
|
|
@@ -333,7 +451,7 @@ const handlers = {
|
|
|
333
451
|
}
|
|
334
452
|
}
|
|
335
453
|
|
|
336
|
-
const ms =
|
|
454
|
+
const ms = elapsedMs(metadata);
|
|
337
455
|
const response = {
|
|
338
456
|
taskId: metadata.taskId, status: metadata.status,
|
|
339
457
|
elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
|
|
@@ -372,7 +490,8 @@ const handlers = {
|
|
|
372
490
|
const cwd = project || getProjectDir(input.project);
|
|
373
491
|
const sessionDir = safeSessionDir(cwd, input.taskId);
|
|
374
492
|
if (!fs.existsSync(sessionDir)) {
|
|
375
|
-
return textResult(`Session ${input.taskId} not found
|
|
493
|
+
return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
|
|
494
|
+
'If you ran it in a different project, pass the original "project".', true);
|
|
376
495
|
}
|
|
377
496
|
|
|
378
497
|
const readMeta = (() => {
|
|
@@ -404,15 +523,26 @@ const handlers = {
|
|
|
404
523
|
}
|
|
405
524
|
// Default: summary
|
|
406
525
|
const summaryPath = path.join(sessionDir, 'summary.md');
|
|
407
|
-
if (!fs.existsSync(summaryPath)) {
|
|
408
|
-
return textResult('No summary available (session may still be running or was not folded).');
|
|
409
|
-
}
|
|
410
526
|
const metaForRead = (() => {
|
|
411
527
|
try { return JSON.parse(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8')); }
|
|
412
528
|
catch { return {}; }
|
|
413
529
|
})();
|
|
414
|
-
const summaryText = fs.
|
|
530
|
+
const summaryText = fs.existsSync(summaryPath)
|
|
531
|
+
? fs.readFileSync(summaryPath, 'utf-8')
|
|
532
|
+
: '';
|
|
415
533
|
const header = metaForRead.model ? `**Model:** ${metaForRead.model}\n\n` : '';
|
|
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()) {
|
|
540
|
+
const reason = metaForRead.reason || 'Unknown error';
|
|
541
|
+
return textResult(`${header}**Status:** ${metaForRead.status}\n**Reason:** ${reason}\n\n(No summary — the session ended in status '${metaForRead.status}'.)`);
|
|
542
|
+
}
|
|
543
|
+
if (!summaryText.trim()) {
|
|
544
|
+
return textResult('No summary available (session may still be running or was not folded).');
|
|
545
|
+
}
|
|
416
546
|
return textResult(header + summaryText);
|
|
417
547
|
},
|
|
418
548
|
|
|
@@ -495,6 +625,7 @@ const handlers = {
|
|
|
495
625
|
try { spawnSidecarProcess(args, sessionDir); } catch (err) {
|
|
496
626
|
return textResult(`Failed to continue: ${err.message}`, true);
|
|
497
627
|
}
|
|
628
|
+
recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
|
|
498
629
|
return textResult(JSON.stringify({
|
|
499
630
|
taskId: newTaskId, status: 'running',
|
|
500
631
|
message: 'Continuation started. Use amicus_status to check progress.',
|
|
@@ -504,7 +635,10 @@ const handlers = {
|
|
|
504
635
|
async amicus_abort(input, project) {
|
|
505
636
|
const cwd = project || getProjectDir(input.project);
|
|
506
637
|
const metadata = readMetadata(input.taskId, cwd);
|
|
507
|
-
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
|
+
}
|
|
508
642
|
if (metadata.status !== 'running') {
|
|
509
643
|
return textResult(`Session ${input.taskId} is not running (status: ${metadata.status}).`);
|
|
510
644
|
}
|
|
@@ -576,6 +710,10 @@ const handlers = {
|
|
|
576
710
|
taskId: waveId, type: 'wave', status: 'running', legs: legIds,
|
|
577
711
|
models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
|
|
578
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); }
|
|
579
717
|
} catch (err) {
|
|
580
718
|
return textResult(`Failed to prepare fan-out wave: ${err.message}`, true);
|
|
581
719
|
}
|
|
@@ -636,6 +774,15 @@ const handlers = {
|
|
|
636
774
|
},
|
|
637
775
|
|
|
638
776
|
async amicus_setup() {
|
|
777
|
+
const { checkElectronAvailable } = require('./sidecar/interactive');
|
|
778
|
+
if (!checkElectronAvailable()) {
|
|
779
|
+
return textResult(
|
|
780
|
+
'The setup GUI cannot open because Electron is not installed, so no '
|
|
781
|
+
+ 'window appeared. Run `amicus setup` in your terminal instead — it '
|
|
782
|
+
+ 'falls back to a headless (readline) wizard for API key configuration.',
|
|
783
|
+
true
|
|
784
|
+
);
|
|
785
|
+
}
|
|
639
786
|
try { spawnSidecarProcess(['setup']); } catch (err) {
|
|
640
787
|
return textResult(`Failed to launch setup: ${err.message}`, true);
|
|
641
788
|
}
|
|
@@ -662,14 +809,22 @@ const LEGACY_TOOL_ALIASES = {
|
|
|
662
809
|
async function startMcpServer() {
|
|
663
810
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
664
811
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
665
|
-
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
|
+
);
|
|
666
818
|
|
|
667
819
|
for (const tool of getTools()) {
|
|
668
820
|
const register = (name) => server.registerTool(
|
|
669
821
|
name,
|
|
670
822
|
{ description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations },
|
|
671
823
|
async (input) => {
|
|
672
|
-
try {
|
|
824
|
+
try {
|
|
825
|
+
const project = await resolveProjectDir(input.project, server);
|
|
826
|
+
return await handlers[tool.name](input, project);
|
|
827
|
+
}
|
|
673
828
|
catch (err) {
|
|
674
829
|
logger.error(`MCP tool error: ${name}`, { error: err.message });
|
|
675
830
|
return textResult(`Error: ${err.message}`, true);
|
|
@@ -692,4 +847,7 @@ async function startMcpServer() {
|
|
|
692
847
|
process.stderr.write('[amicus] MCP server running on stdio\n');
|
|
693
848
|
}
|
|
694
849
|
|
|
695
|
-
module.exports = {
|
|
850
|
+
module.exports = {
|
|
851
|
+
handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
|
|
852
|
+
LEGACY_TOOL_ALIASES,
|
|
853
|
+
};
|
package/src/opencode-client.js
CHANGED
|
@@ -27,6 +27,40 @@ async function getCreateOpencodeServer() {
|
|
|
27
27
|
return sdk.createOpencodeServer;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Shared reason string for an HTTP 402 from the provider.
|
|
32
|
+
* Exported so #36 and a later pre-flight credit check can reuse the exact phrasing
|
|
33
|
+
* instead of re-deriving it.
|
|
34
|
+
*/
|
|
35
|
+
const INSUFFICIENT_CREDITS_REASON = 'Insufficient credits';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map an SDK request result to a propagated provider-failure reason.
|
|
39
|
+
*
|
|
40
|
+
* Keys on the actual HTTP status (result.response.status, falling back to
|
|
41
|
+
* result.error.status) so benign informational `error` payloads that ride on a
|
|
42
|
+
* 2xx response stay "continue to poll" — only a real non-2xx status becomes a
|
|
43
|
+
* session error.
|
|
44
|
+
*
|
|
45
|
+
* 402 -> 'Insufficient credits'
|
|
46
|
+
* other non-2xx -> 'Provider error: <status>'
|
|
47
|
+
* 2xx / unknown -> null
|
|
48
|
+
*
|
|
49
|
+
* @param {{response?: {status?: number}, error?: {status?: number}}} [result]
|
|
50
|
+
* @returns {string|null} Reason string, or null when there is no hard failure.
|
|
51
|
+
*/
|
|
52
|
+
function providerErrorReason(result) {
|
|
53
|
+
if (!result) { return null; }
|
|
54
|
+
const status = (result.response && result.response.status)
|
|
55
|
+
|| (result.error && result.error.status);
|
|
56
|
+
// No usable status — cannot distinguish a benign warning from a failure, so
|
|
57
|
+
// do not regress the "continue to poll" behavior.
|
|
58
|
+
if (typeof status !== 'number') { return null; }
|
|
59
|
+
if (status >= 200 && status < 300) { return null; }
|
|
60
|
+
if (status === 402) { return INSUFFICIENT_CREDITS_REASON; }
|
|
61
|
+
return `Provider error: ${status}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
30
64
|
/**
|
|
31
65
|
* Parse a model string into SDK format
|
|
32
66
|
*
|
|
@@ -73,15 +107,34 @@ async function createClient(baseUrl) {
|
|
|
73
107
|
return createOpencodeClient(config);
|
|
74
108
|
}
|
|
75
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
|
+
|
|
76
126
|
/**
|
|
77
127
|
* Create a new session
|
|
78
128
|
*
|
|
79
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.
|
|
80
133
|
* @returns {Promise<string>} Session ID
|
|
81
134
|
* @throws {Error} If session creation fails
|
|
82
135
|
*/
|
|
83
|
-
async function createSession(client) {
|
|
84
|
-
const result = await client.session.create({});
|
|
136
|
+
async function createSession(client, directory) {
|
|
137
|
+
const result = await client.session.create({ ...directoryQuery(directory) });
|
|
85
138
|
|
|
86
139
|
if (result.error) {
|
|
87
140
|
throw new Error(result.error.message || 'Failed to create session');
|
|
@@ -111,10 +164,13 @@ async function createSession(client) {
|
|
|
111
164
|
* @param {object} [options.reasoning] - Reasoning/thinking configuration
|
|
112
165
|
* @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
|
|
113
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.
|
|
114
170
|
* @returns {Promise<object>} API response
|
|
115
171
|
*/
|
|
116
172
|
async function sendPrompt(client, sessionId, options) {
|
|
117
|
-
const { model, system, parts, agent, tools, reasoning, watchdog } = options;
|
|
173
|
+
const { model, system, parts, agent, tools, reasoning, watchdog, directory } = options;
|
|
118
174
|
|
|
119
175
|
// Parse model string to SDK format
|
|
120
176
|
const modelSpec = parseModelString(model);
|
|
@@ -151,7 +207,8 @@ async function sendPrompt(client, sessionId, options) {
|
|
|
151
207
|
try {
|
|
152
208
|
result = await client.session.promptAsync({
|
|
153
209
|
path: { id: sessionId },
|
|
154
|
-
body
|
|
210
|
+
body,
|
|
211
|
+
...directoryQuery(directory)
|
|
155
212
|
});
|
|
156
213
|
} finally {
|
|
157
214
|
if (watchdog) {
|
|
@@ -159,11 +216,25 @@ async function sendPrompt(client, sessionId, options) {
|
|
|
159
216
|
}
|
|
160
217
|
}
|
|
161
218
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
219
|
+
// Detect a hard provider failure at the client boundary (#37). A non-2xx /
|
|
220
|
+
// 402 here must surface as a session error EVEN WHEN the server emits no
|
|
221
|
+
// assistant message carrying info.error — otherwise the run looks idle/empty.
|
|
222
|
+
// Keyed on HTTP status so benign informational errors (model config warnings
|
|
223
|
+
// on a 2xx) keep the fire-and-forget "continue to poll" behavior below.
|
|
224
|
+
const reason = providerErrorReason(result);
|
|
225
|
+
if (reason) {
|
|
226
|
+
result.providerError = reason;
|
|
227
|
+
const { logger } = require('./utils/logger');
|
|
228
|
+
logger.error('promptAsync returned a hard provider error', {
|
|
229
|
+
reason,
|
|
230
|
+
status: (result.response && result.response.status) || (result.error && result.error.status),
|
|
231
|
+
sessionId
|
|
232
|
+
});
|
|
233
|
+
} else if (result.error) {
|
|
234
|
+
// Log but don't throw on benign promptAsync errors.
|
|
235
|
+
// promptAsync is fire-and-forget: the server queues the prompt for async
|
|
236
|
+
// processing. These errors are informational (e.g., model config warnings)
|
|
237
|
+
// rather than fatal. The polling loop will detect real failures via timeout.
|
|
167
238
|
const { logger } = require('./utils/logger');
|
|
168
239
|
logger.error('promptAsync returned error (continuing to poll)', {
|
|
169
240
|
error: result.error.message || JSON.stringify(result.error),
|
|
@@ -179,11 +250,15 @@ async function sendPrompt(client, sessionId, options) {
|
|
|
179
250
|
*
|
|
180
251
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
181
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.
|
|
182
256
|
* @returns {Promise<Array>} Array of messages
|
|
183
257
|
*/
|
|
184
|
-
async function getMessages(client, sessionId) {
|
|
258
|
+
async function getMessages(client, sessionId, directory) {
|
|
185
259
|
const result = await client.session.messages({
|
|
186
|
-
path: { id: sessionId }
|
|
260
|
+
path: { id: sessionId },
|
|
261
|
+
...directoryQuery(directory)
|
|
187
262
|
});
|
|
188
263
|
|
|
189
264
|
return result.data || [];
|
|
@@ -266,10 +341,13 @@ async function listSessions(client) {
|
|
|
266
341
|
*
|
|
267
342
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
268
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.
|
|
269
347
|
* @returns {Promise<void>}
|
|
270
348
|
*/
|
|
271
|
-
async function abortSession(client, sessionId) {
|
|
272
|
-
await client.session.abort({ path: { id: sessionId } });
|
|
349
|
+
async function abortSession(client, sessionId, directory) {
|
|
350
|
+
await client.session.abort({ path: { id: sessionId }, ...directoryQuery(directory) });
|
|
273
351
|
}
|
|
274
352
|
|
|
275
353
|
/**
|
|
@@ -277,11 +355,15 @@ async function abortSession(client, sessionId) {
|
|
|
277
355
|
*
|
|
278
356
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
279
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.
|
|
280
361
|
* @returns {Promise<Object>} Session status
|
|
281
362
|
*/
|
|
282
|
-
async function getSessionStatus(client, sessionId) {
|
|
363
|
+
async function getSessionStatus(client, sessionId, directory) {
|
|
283
364
|
const result = await client.session.status({
|
|
284
|
-
path: { id: sessionId }
|
|
365
|
+
path: { id: sessionId },
|
|
366
|
+
...directoryQuery(directory)
|
|
285
367
|
});
|
|
286
368
|
|
|
287
369
|
return result.data || {};
|
|
@@ -595,6 +677,8 @@ function parseMcpSpec(spec) {
|
|
|
595
677
|
}
|
|
596
678
|
|
|
597
679
|
module.exports = {
|
|
680
|
+
INSUFFICIENT_CREDITS_REASON,
|
|
681
|
+
providerErrorReason,
|
|
598
682
|
parseModelString,
|
|
599
683
|
createClient,
|
|
600
684
|
createSession,
|
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
|
/**
|
package/src/sidecar/continue.js
CHANGED
|
@@ -200,8 +200,9 @@ async function continueSidecar(options) {
|
|
|
200
200
|
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
201
201
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
202
202
|
|
|
203
|
-
// Finalize session
|
|
204
|
-
|
|
203
|
+
// Finalize session. Interactive mode legitimately returns an empty summary,
|
|
204
|
+
// so pass status explicitly to stay out of the #36 empty-summary guard.
|
|
205
|
+
finalizeSession(sessionDir, summary, project, meta, { status: 'complete' });
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
module.exports = {
|