amicus 1.9.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +200 -0
- package/README.md +40 -170
- package/bin/amicus.js +19 -107
- package/commands/council.md +7 -3
- package/electron/fold.js +10 -1
- package/electron/ipc-setup.js +10 -15
- package/electron/main.js +21 -16
- package/electron/preload-setup.js +0 -1
- package/electron/setup-ui-council.js +64 -10
- package/electron/setup-ui-styles.js +34 -3
- package/electron/setup-ui.js +44 -12
- package/package.json +2 -5
- package/skills/second-opinion/MODEL-NOTES.md +2 -2
- package/skills/second-opinion/SKILL.md +30 -28
- package/skills/sidecar/SKILL.md +20 -17
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +20 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +9 -8
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +55 -0
- package/src/council/presets-cli.js +141 -0
- package/src/headless.js +146 -38
- package/src/index.js +1 -9
- package/src/mcp-server.js +140 -113
- package/src/mcp-tools.js +58 -24
- package/src/mcp-wait.js +8 -5
- package/src/opencode-client.js +33 -10
- package/src/prompt-builder.js +32 -11
- package/src/session-manager.js +7 -14
- package/src/sidecar/continue.js +34 -12
- package/src/sidecar/conversation-mirror.js +22 -1
- package/src/sidecar/crash-handler.js +2 -1
- package/src/sidecar/fanout-leg.js +12 -3
- package/src/sidecar/fanout.js +27 -10
- package/src/sidecar/interactive-process.js +6 -17
- package/src/sidecar/interactive.js +5 -6
- package/src/sidecar/models.js +33 -4
- package/src/sidecar/progress.js +2 -1
- package/src/sidecar/read.js +4 -6
- package/src/sidecar/resume.js +41 -11
- package/src/sidecar/session-finalize.js +2 -1
- package/src/sidecar/session-utils.js +13 -35
- package/src/sidecar/setup-window.js +2 -3
- package/src/sidecar/start.js +22 -7
- package/src/utils/abort-coordinator.js +57 -7
- package/src/utils/abort-result.js +36 -0
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/doctor-mcp-checks.js +84 -0
- package/src/utils/env-loader.js +1 -2
- package/src/utils/fold-marker.js +79 -0
- package/src/utils/idle-watchdog.js +9 -12
- package/src/utils/input-validators.js +52 -1
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +80 -19
- package/src/utils/mcp-self-identity.js +12 -5
- package/src/utils/model-catalog.js +54 -6
- package/src/utils/read-slice.js +73 -0
- package/src/utils/remediation-hints.js +9 -0
- package/src/utils/result-schema-version.js +14 -0
- package/src/utils/result-schema.js +18 -12
- package/src/utils/session-abort.js +1 -1
- package/src/utils/session-index-tmp-sweep.js +80 -0
- package/src/utils/session-index.js +4 -5
- package/src/utils/session-path.js +6 -10
- package/src/utils/shared-server.js +7 -5
- package/src/utils/spend-ledger.js +80 -0
- package/src/utils/updater.js +2 -3
- package/src/utils/env-compat.js +0 -38
|
@@ -11,7 +11,6 @@ const { startOpenCodeServer } = require('./session-utils');
|
|
|
11
11
|
const { createSession, sendPromptAsync, getMessages, abortSession } = require('../opencode-client');
|
|
12
12
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
13
13
|
const { logger } = require('../utils/logger');
|
|
14
|
-
const { getCompatEnv } = require('../utils/env-compat');
|
|
15
14
|
const { startInteractiveMirror } = require('./interactive-mirror');
|
|
16
15
|
const { startAbortWatch, markResultAborted, readAbortedMarker } = require('./interactive-abort');
|
|
17
16
|
const { getSessionDir } = require('../session-manager');
|
|
@@ -34,7 +33,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
34
33
|
};
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client } = options;
|
|
36
|
+
const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client, foldNonce } = options;
|
|
38
37
|
|
|
39
38
|
// F6c: mirror headless's lifecycle stages (best-effort — a write failure must
|
|
40
39
|
// never break the GUI) so the heartbeat/status never read "Starting up...".
|
|
@@ -101,7 +100,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
101
100
|
}
|
|
102
101
|
logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
|
|
103
102
|
} catch (error) {
|
|
104
|
-
server.close();
|
|
103
|
+
server.close().catch(() => {});
|
|
105
104
|
return {
|
|
106
105
|
summary: '', completed: false, timedOut: false, taskId,
|
|
107
106
|
error: `Session setup failed: ${error.message}`
|
|
@@ -157,12 +156,12 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
157
156
|
const existingPath = process.env.PATH || '';
|
|
158
157
|
const env = buildElectronEnv(
|
|
159
158
|
taskId, model, project, nodeModulesBin, existingPath,
|
|
160
|
-
{ agent, isResume, conversation, mcp, client, sessionDirectory }
|
|
159
|
+
{ agent, isResume, conversation, mcp, client, sessionDirectory, foldNonce }
|
|
161
160
|
);
|
|
162
161
|
env.AMICUS_OPENCODE_PORT = serverPort;
|
|
163
162
|
env.AMICUS_SESSION_ID = sessionId;
|
|
164
163
|
|
|
165
|
-
const debugPort =
|
|
164
|
+
const debugPort = process.env.AMICUS_DEBUG_PORT || '9222';
|
|
166
165
|
logger.debug('Launching Electron', { taskId, model, debugPort, serverPort, sessionId });
|
|
167
166
|
|
|
168
167
|
electronProcess = spawn(electronPath, [
|
|
@@ -200,7 +199,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
200
199
|
const { usage } = await mirror.stop();
|
|
201
200
|
if (usage) { result.usage = usage; }
|
|
202
201
|
} catch (err) { logger.debug('mirror stop failed', { error: err.message }); }
|
|
203
|
-
server.close();
|
|
202
|
+
try { await server.close(); } catch { /* best-effort */ }
|
|
204
203
|
logger.debug('OpenCode server closed after Electron exit');
|
|
205
204
|
result.opencodeSessionId = sessionId;
|
|
206
205
|
resolve(result);
|
package/src/sidecar/models.js
CHANGED
|
@@ -47,15 +47,29 @@ function aliasMarks() {
|
|
|
47
47
|
return map;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* #13: a one-line honest memo when the last refresh attempt on record failed
|
|
52
|
+
* AFTER the data currently being shown was fetched — i.e. the cache is stale
|
|
53
|
+
* because refreshing keeps failing, not just because nobody's refreshed lately.
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
function staleMemo(fetchedAt, lastRefreshAttempt, lastRefreshError) {
|
|
57
|
+
if (!lastRefreshAttempt || !lastRefreshError) { return null; }
|
|
58
|
+
if (fetchedAt && lastRefreshAttempt <= fetchedAt) { return null; }
|
|
59
|
+
const attemptWhen = new Date(lastRefreshAttempt).toISOString();
|
|
60
|
+
const fetchedWhen = fetchedAt ? new Date(fetchedAt).toISOString() : 'never';
|
|
61
|
+
return `⚠ catalog may be stale: last refresh attempt failed ${attemptWhen} (${lastRefreshError}); showing data fetched ${fetchedWhen}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
async function runList(args) {
|
|
51
|
-
const { models, fetchedAt } = await getCatalogInfo();
|
|
65
|
+
const { models, fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo();
|
|
52
66
|
const q = typeof args.search === 'string' ? args.search.toLowerCase() : null;
|
|
53
67
|
const filtered = q
|
|
54
68
|
? models.filter(m => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
|
|
55
69
|
: models;
|
|
56
70
|
if (args.json) {
|
|
57
71
|
process.stdout.write(JSON.stringify(buildCatalogDoc({
|
|
58
|
-
models: filtered, fetchedAt, search: q
|
|
72
|
+
models: filtered, fetchedAt, search: q, lastRefreshAttempt, lastRefreshError
|
|
59
73
|
}), null, 2) + '\n');
|
|
60
74
|
return 0;
|
|
61
75
|
}
|
|
@@ -71,16 +85,31 @@ async function runList(args) {
|
|
|
71
85
|
if (filtered.length === 0 && models.length === 0) {
|
|
72
86
|
process.stdout.write('Catalog unavailable (offline or first run) — try: amicus models --refresh\n');
|
|
73
87
|
}
|
|
88
|
+
const memo = staleMemo(fetchedAt, lastRefreshAttempt, lastRefreshError);
|
|
89
|
+
if (memo) { process.stdout.write(memo + '\n'); }
|
|
74
90
|
return 0;
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
async function runRefresh(args) {
|
|
78
94
|
const models = await refreshCatalog();
|
|
95
|
+
const { fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
79
96
|
if (args.json) {
|
|
80
97
|
process.stdout.write(JSON.stringify(buildCatalogDoc({
|
|
81
|
-
models, fetchedAt
|
|
98
|
+
models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError
|
|
82
99
|
}), null, 2) + '\n');
|
|
83
|
-
return 0;
|
|
100
|
+
return models.length === 0 && !fetchedAt ? 1 : 0;
|
|
101
|
+
}
|
|
102
|
+
if (models.length === 0 && lastRefreshError) {
|
|
103
|
+
// Honest failure report — never claim "Refreshed catalog: 0 models" when
|
|
104
|
+
// the refresh actually failed and an old (or no) cache was retained.
|
|
105
|
+
if (fetchedAt) {
|
|
106
|
+
const when = new Date(fetchedAt).toISOString();
|
|
107
|
+
process.stdout.write(`refresh failed (${lastRefreshError}); keeping catalog from ${when}\n`);
|
|
108
|
+
process.stdout.write(`Cache: ${catalogPath()}\n`);
|
|
109
|
+
return 0; // stale-but-served: a warning, not a command failure
|
|
110
|
+
}
|
|
111
|
+
process.stdout.write(`refresh failed (${lastRefreshError}); no cache available\n`);
|
|
112
|
+
return 1; // no cache at all: a real failure
|
|
84
113
|
}
|
|
85
114
|
process.stdout.write(`Refreshed catalog: ${models.length} models.\n`);
|
|
86
115
|
process.stdout.write(`Cache: ${catalogPath()}\n`);
|
package/src/sidecar/progress.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { latestAssistantPreview } = require('./progress-fields');
|
|
13
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
13
14
|
|
|
14
15
|
/** Lifecycle stage labels */
|
|
15
16
|
const STAGE_LABELS = {
|
|
@@ -117,7 +118,7 @@ function writeProgress(sessionDir, stage, extra = {}) {
|
|
|
117
118
|
updatedAt: new Date().toISOString(),
|
|
118
119
|
...extra
|
|
119
120
|
};
|
|
120
|
-
|
|
121
|
+
writeFileAtomic(progressPath, JSON.stringify(data), { mode: 0o600 });
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
/**
|
package/src/sidecar/read.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const { safeSessionDir, TASK_ID_PATTERN } = require('../utils/validators');
|
|
11
|
-
const { SESSIONS_DIR
|
|
11
|
+
const { SESSIONS_DIR } = require('../session-manager');
|
|
12
12
|
const { fenceSidecarOutput } = require('../utils/untrusted-fence');
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -31,18 +31,16 @@ function formatAge(dateStr) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Enumerate sessions
|
|
34
|
+
* Enumerate sessions under the canonical amicus_sessions root.
|
|
35
35
|
* @param {string} project
|
|
36
36
|
* @param {{status?: string}} [opts] - status filter ('running', etc.); omit/'all' for all
|
|
37
37
|
* @returns {Array<{id, model, status, agent, briefing, createdAt}>}
|
|
38
38
|
*/
|
|
39
39
|
function enumerateSessions(project, opts = {}) {
|
|
40
|
-
const
|
|
41
|
-
.map(d => path.join(project, '.claude', d))
|
|
42
|
-
.filter(fs.existsSync);
|
|
40
|
+
const root = path.join(project, '.claude', SESSIONS_DIR);
|
|
43
41
|
|
|
44
42
|
const byId = new Map();
|
|
45
|
-
|
|
43
|
+
if (fs.existsSync(root)) {
|
|
46
44
|
for (const d of fs.readdirSync(root)) {
|
|
47
45
|
if (!TASK_ID_PATTERN.test(d)) { continue; }
|
|
48
46
|
if (byId.has(d)) { continue; }
|
package/src/sidecar/resume.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
|
|
9
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
9
10
|
const { runInteractive, buildMcpConfig } = require('./start');
|
|
10
11
|
const {
|
|
11
12
|
SessionPaths,
|
|
@@ -16,6 +17,7 @@ const {
|
|
|
16
17
|
} = require('./session-utils');
|
|
17
18
|
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
18
19
|
const { runHeadless } = require('../headless');
|
|
20
|
+
const { extractNonceFromText, generateFoldNonce } = require('../utils/fold-marker');
|
|
19
21
|
const { logger } = require('../utils/logger');
|
|
20
22
|
|
|
21
23
|
/** Load session metadata from session directory */
|
|
@@ -106,7 +108,7 @@ function updateSessionStatus(sessionDir, status) {
|
|
|
106
108
|
if (status === 'running') {
|
|
107
109
|
meta.resumedAt = new Date().toISOString();
|
|
108
110
|
}
|
|
109
|
-
|
|
111
|
+
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2));
|
|
110
112
|
return meta;
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -117,7 +119,7 @@ function updateSessionStatus(sessionDir, status) {
|
|
|
117
119
|
async function resumeSidecar(options) {
|
|
118
120
|
const {
|
|
119
121
|
taskId, project = process.cwd(), headless = false, timeout = 15,
|
|
120
|
-
mcp, mcpConfig, client, noMcp, excludeMcp
|
|
122
|
+
mcp, mcpConfig, client, noMcp, excludeMcp, json = false
|
|
121
123
|
} = options;
|
|
122
124
|
|
|
123
125
|
// Resume operates on an EXISTING session — resolve dual-dir (amicus, then legacy).
|
|
@@ -159,6 +161,18 @@ async function resumeSidecar(options) {
|
|
|
159
161
|
logger.warn('Files changed since last activity', { taskId, changedFileCount: drift.changedFiles.length });
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
// 15b.3: resume re-sends the ORIGINAL prompt text verbatim (unlike
|
|
165
|
+
// continue, which builds a fresh one) — that prompt already instructed
|
|
166
|
+
// the model with the nonce baked in at the initial start/continue time.
|
|
167
|
+
// Recover it from the saved text so the detector agrees with what the
|
|
168
|
+
// model was actually told. A session saved before 15b.3 shipped (or any
|
|
169
|
+
// prompt that somehow lost its marker instruction) has no nonce to
|
|
170
|
+
// recover — generate a fresh one so resume still gets nonce protection,
|
|
171
|
+
// even though the OLD prompt text won't mention it (that just means this
|
|
172
|
+
// resumed run can only complete via a non-fold-marker path, same as any
|
|
173
|
+
// other run whose prompt and detector nonce happen to mismatch).
|
|
174
|
+
const foldNonce = extractNonceFromText(systemPrompt) || generateFoldNonce();
|
|
175
|
+
|
|
162
176
|
// Update metadata (get updated metadata with resumedAt)
|
|
163
177
|
const updatedMetadata = updateSessionStatus(sessionDir, 'running');
|
|
164
178
|
|
|
@@ -177,10 +191,17 @@ async function resumeSidecar(options) {
|
|
|
177
191
|
|
|
178
192
|
if (headless) {
|
|
179
193
|
const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
try {
|
|
195
|
+
result = await runHeadless(
|
|
196
|
+
metadata.model, resumePrompt, userMessage,
|
|
197
|
+
taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
|
|
198
|
+
);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (!json) { throw err; }
|
|
201
|
+
// --json contract: stdout must always carry a parseable run doc,
|
|
202
|
+
// even when the engine throws rather than returning {error}.
|
|
203
|
+
result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId };
|
|
204
|
+
}
|
|
184
205
|
summary = result.summary || '## Sidecar Results: No Output\n\nResumed session completed without summary.';
|
|
185
206
|
|
|
186
207
|
if (result.timedOut) { logger.warn('Resume task timed out', { taskId }); }
|
|
@@ -196,15 +217,16 @@ async function resumeSidecar(options) {
|
|
|
196
217
|
isResume: true,
|
|
197
218
|
conversation: existingConversation,
|
|
198
219
|
opencodeSessionId: metadata.opencodeSessionId,
|
|
199
|
-
mcp: mcpServers
|
|
220
|
+
mcp: mcpServers,
|
|
221
|
+
foldNonce
|
|
200
222
|
}
|
|
201
223
|
);
|
|
202
224
|
summary = result.summary || '';
|
|
203
225
|
if (result.error) { logger.error('Interactive resume error', { taskId, error: result.error }); }
|
|
204
226
|
}
|
|
205
227
|
|
|
206
|
-
// Output summary
|
|
207
|
-
outputSummary(summary);
|
|
228
|
+
// Output summary (human mode only — json mode keeps stdout to the doc below)
|
|
229
|
+
if (!json) { outputSummary(summary); }
|
|
208
230
|
|
|
209
231
|
// Map the run result to the canonical terminal status + exit code —
|
|
210
232
|
// mirrors start.js. Explicit status preserves the interactive
|
|
@@ -216,11 +238,19 @@ async function resumeSidecar(options) {
|
|
|
216
238
|
updatedMetadata.status = 'error';
|
|
217
239
|
updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
218
240
|
updatedMetadata.completedAt = new Date().toISOString();
|
|
219
|
-
|
|
241
|
+
writeFileAtomic(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
|
|
220
242
|
logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
|
|
221
243
|
} else {
|
|
222
|
-
finalizeSession(sessionDir, summary, project, updatedMetadata, { status: terminal.status });
|
|
244
|
+
finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status });
|
|
223
245
|
}
|
|
246
|
+
|
|
247
|
+
if (json) {
|
|
248
|
+
const { buildRunResult } = require('../utils/result-schema');
|
|
249
|
+
const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
250
|
+
const doc = buildRunResult({ taskId, metadata: finalMeta, result, summary, sessionDir });
|
|
251
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
252
|
+
}
|
|
253
|
+
|
|
224
254
|
return terminal.exitCode; // finally below still releases the lock first
|
|
225
255
|
} finally {
|
|
226
256
|
if (heartbeat) { heartbeat.stop(); }
|
|
@@ -42,6 +42,7 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
|
|
|
42
42
|
const fs = require('fs');
|
|
43
43
|
const path = require('path');
|
|
44
44
|
const { finalizeSession, SessionPaths } = require('./session-utils');
|
|
45
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
45
46
|
|
|
46
47
|
const terminal = resolveTerminalState(result);
|
|
47
48
|
if (terminal.status === 'error') {
|
|
@@ -51,7 +52,7 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
|
|
|
51
52
|
metadata.status = 'error';
|
|
52
53
|
metadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
53
54
|
metadata.completedAt = new Date().toISOString();
|
|
54
|
-
|
|
55
|
+
writeFileAtomic(
|
|
55
56
|
path.join(sessionDir, 'metadata.json'),
|
|
56
57
|
JSON.stringify(metadata, null, 2),
|
|
57
58
|
{ mode: 0o600 }
|
|
@@ -9,6 +9,11 @@ const path = require('path');
|
|
|
9
9
|
const { detectConflicts, formatConflictWarning } = require('../conflict');
|
|
10
10
|
const { logger } = require('../utils/logger');
|
|
11
11
|
const { fenceSidecarOutput } = require('../utils/untrusted-fence');
|
|
12
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
13
|
+
// isProcessAlive/checkSessionLiveness live in utils/abort-coordinator.js
|
|
14
|
+
// (shared EPERM-aware liveness classification with isAlive); re-exported
|
|
15
|
+
// below for backward-compatible imports.
|
|
16
|
+
const { isAlive: isProcessAlive, checkSessionLiveness } = require('../utils/abort-coordinator');
|
|
12
17
|
const {
|
|
13
18
|
SESSIONS_DIR,
|
|
14
19
|
getSessionDir,
|
|
@@ -31,9 +36,8 @@ const SessionPaths = {
|
|
|
31
36
|
},
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
|
-
* Resolve an EXISTING session directory for READS
|
|
35
|
-
*
|
|
36
|
-
* resuming/continuing an existing session so pre-rebrand sessions are found.
|
|
39
|
+
* Resolve an EXISTING session directory for READS. Use this when
|
|
40
|
+
* resuming/continuing an existing session.
|
|
37
41
|
*/
|
|
38
42
|
resolveSessionDir(project, taskId) {
|
|
39
43
|
return resolveExistingSessionDir(project, taskId);
|
|
@@ -100,7 +104,7 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
|
|
|
100
104
|
const hasSummary = typeof summary === 'string' && summary.trim().length > 0;
|
|
101
105
|
metadata.status = opts.status || (hasSummary ? 'complete' : 'error');
|
|
102
106
|
metadata.completedAt = new Date().toISOString();
|
|
103
|
-
|
|
107
|
+
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
104
108
|
|
|
105
109
|
logger.info('Session finalized', { taskId: metadata.taskId, status: metadata.status });
|
|
106
110
|
}
|
|
@@ -248,43 +252,17 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
|
|
|
248
252
|
|
|
249
253
|
const ready = await waitForServer(client, checkHealth);
|
|
250
254
|
if (!ready) {
|
|
251
|
-
|
|
255
|
+
// Fire-and-forget: today close() is sync (Promise.resolve wraps a
|
|
256
|
+
// non-promise harmlessly); once close() becomes async (bounded
|
|
257
|
+
// kill-escalation poll) this guard prevents an unhandled rejection
|
|
258
|
+
// from racing the throw below.
|
|
259
|
+
Promise.resolve(server.close()).catch(() => {});
|
|
252
260
|
throw new Error('OpenCode server failed to become ready');
|
|
253
261
|
}
|
|
254
262
|
|
|
255
263
|
return { client, server };
|
|
256
264
|
}
|
|
257
265
|
|
|
258
|
-
/**
|
|
259
|
-
* Check if a process with the given PID is still alive.
|
|
260
|
-
* @param {number|null} pid
|
|
261
|
-
* @returns {boolean}
|
|
262
|
-
*/
|
|
263
|
-
function isProcessAlive(pid) {
|
|
264
|
-
if (!pid) { return false; }
|
|
265
|
-
try {
|
|
266
|
-
process.kill(pid, 0);
|
|
267
|
-
return true;
|
|
268
|
-
} catch {
|
|
269
|
-
return false;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Check if a session's processes are alive.
|
|
275
|
-
* @param {Object} metadata - Session metadata with pid and goPid
|
|
276
|
-
* @returns {'alive'|'server-dead'|'dead'}
|
|
277
|
-
*/
|
|
278
|
-
function checkSessionLiveness(metadata) {
|
|
279
|
-
if (!metadata) { return 'dead'; }
|
|
280
|
-
const nodeAlive = isProcessAlive(metadata.pid);
|
|
281
|
-
const goAlive = isProcessAlive(metadata.goPid);
|
|
282
|
-
|
|
283
|
-
if (nodeAlive && goAlive) { return 'alive'; }
|
|
284
|
-
if (nodeAlive && !goAlive) { return 'server-dead'; }
|
|
285
|
-
return 'dead';
|
|
286
|
-
}
|
|
287
|
-
|
|
288
266
|
module.exports = {
|
|
289
267
|
HEARTBEAT_INTERVAL,
|
|
290
268
|
SessionPaths,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Setup Window Launcher
|
|
3
3
|
*
|
|
4
|
-
* Spawns the Electron window in setup mode (
|
|
4
|
+
* Spawns the Electron window in setup mode (AMICUS_MODE=setup)
|
|
5
5
|
* for API key configuration. Waits for the window to close and
|
|
6
6
|
* returns whether setup completed successfully.
|
|
7
7
|
*/
|
|
@@ -11,7 +11,6 @@ const path = require('path');
|
|
|
11
11
|
const { logger } = require('../utils/logger');
|
|
12
12
|
const { getElectronPath } = require('./interactive-process');
|
|
13
13
|
const { ensureElectron } = require('./electron-ensure');
|
|
14
|
-
const { getCompatEnv } = require('../utils/env-compat');
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* Launch the Electron setup window for API key entry.
|
|
@@ -35,7 +34,7 @@ async function launchSetupWindow() {
|
|
|
35
34
|
AMICUS_MODE: 'setup'
|
|
36
35
|
};
|
|
37
36
|
|
|
38
|
-
const debugPort =
|
|
37
|
+
const debugPort = process.env.AMICUS_DEBUG_PORT;
|
|
39
38
|
const args = debugPort
|
|
40
39
|
? [`--remote-debugging-port=${debugPort}`, mainPath]
|
|
41
40
|
: [mainPath];
|
package/src/sidecar/start.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const crypto = require('crypto');
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
|
|
9
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
9
10
|
const { buildContext } = require('./context-builder');
|
|
10
11
|
const {
|
|
11
12
|
SessionPaths,
|
|
@@ -25,6 +26,7 @@ const { loadMcpConfig, parseMcpSpec } = require('../opencode-client');
|
|
|
25
26
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
26
27
|
const { discoverParentMcps } = require('../utils/mcp-discovery');
|
|
27
28
|
const { stripSelfMcpEntries } = require('../utils/mcp-self-identity');
|
|
29
|
+
const { generateFoldNonce } = require('../utils/fold-marker');
|
|
28
30
|
|
|
29
31
|
/** Generate a unique 8-character hex task ID */
|
|
30
32
|
function generateTaskId() {
|
|
@@ -66,7 +68,7 @@ function createSessionMetadata(taskId, project, options) {
|
|
|
66
68
|
createdAt: existing.createdAt || new Date().toISOString()
|
|
67
69
|
};
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
70
72
|
|
|
71
73
|
return sessionDir;
|
|
72
74
|
}
|
|
@@ -163,6 +165,12 @@ async function startSidecar(options) {
|
|
|
163
165
|
});
|
|
164
166
|
const taskId = options.taskId || generateTaskId();
|
|
165
167
|
const reasoning = thinking ? { effort: thinking } : undefined;
|
|
168
|
+
// 15b.3: one nonce per run, generated BEFORE prompt construction so the
|
|
169
|
+
// SAME value can be baked into the prompt's instruction (buildPrompts) and
|
|
170
|
+
// handed to the detector (runHeadless.options.nonce / the GUI fold writer
|
|
171
|
+
// via env). Harmless to generate even for the interactive path — buildPrompts
|
|
172
|
+
// only consumes it in headless mode.
|
|
173
|
+
const foldNonce = generateFoldNonce();
|
|
166
174
|
|
|
167
175
|
logger.info('Starting task', { taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive' });
|
|
168
176
|
|
|
@@ -170,7 +178,7 @@ async function startSidecar(options) {
|
|
|
170
178
|
? buildContext(effectiveProject, effectiveSession, { contextTurns, contextSince, contextMaxTokens, sessionDir, client, coworkProcess })
|
|
171
179
|
: '[Context excluded by caller - briefing is self-contained]';
|
|
172
180
|
const { system: systemPrompt, userMessage } = buildPrompts(
|
|
173
|
-
effectivePrompt, context, effectiveProject, effectiveHeadless, agent, summaryLength, client
|
|
181
|
+
effectivePrompt, context, effectiveProject, effectiveHeadless, agent, summaryLength, client, foldNonce
|
|
174
182
|
);
|
|
175
183
|
|
|
176
184
|
const sessDir = createSessionMetadata(taskId, effectiveProject, {
|
|
@@ -188,7 +196,8 @@ async function startSidecar(options) {
|
|
|
188
196
|
try {
|
|
189
197
|
result = await runHeadless(
|
|
190
198
|
model, systemPrompt, userMessage, taskId, effectiveProject,
|
|
191
|
-
timeout * 60 * 1000, agent || 'build',
|
|
199
|
+
timeout * 60 * 1000, agent || 'build',
|
|
200
|
+
{ mcp: mcpServers, summaryLength, reasoning, port: opencodePort, nonce: foldNonce }
|
|
192
201
|
);
|
|
193
202
|
} catch (err) {
|
|
194
203
|
if (!json) { throw err; }
|
|
@@ -204,7 +213,7 @@ async function startSidecar(options) {
|
|
|
204
213
|
logger.info('Launching interactive sidecar', { taskId, model, agent: effectiveAgent });
|
|
205
214
|
result = await runInteractive(
|
|
206
215
|
model, systemPrompt, userMessage, taskId, effectiveProject,
|
|
207
|
-
{ agent, mcp: mcpServers, reasoning, client, windowPosition: position }
|
|
216
|
+
{ agent, mcp: mcpServers, reasoning, client, windowPosition: position, foldNonce }
|
|
208
217
|
);
|
|
209
218
|
summary = result.summary || '';
|
|
210
219
|
if (result.error) { logger.error('Interactive task error', { taskId, error: result.error }); }
|
|
@@ -221,7 +230,7 @@ async function startSidecar(options) {
|
|
|
221
230
|
// Persist OpenCode session ID for resume capability
|
|
222
231
|
if (result && result.opencodeSessionId) {
|
|
223
232
|
meta.opencodeSessionId = result.opencodeSessionId;
|
|
224
|
-
|
|
233
|
+
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
// Map the run result to a definitive terminal status + exit code (single source of truth).
|
|
@@ -231,7 +240,7 @@ async function startSidecar(options) {
|
|
|
231
240
|
meta.status = 'error';
|
|
232
241
|
meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
233
242
|
meta.completedAt = new Date().toISOString();
|
|
234
|
-
|
|
243
|
+
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
235
244
|
logger.error('Session completed with error', { taskId, error: meta.reason });
|
|
236
245
|
} else {
|
|
237
246
|
// complete / timed-out / aborted: persist the (possibly partial) summary with the correct status.
|
|
@@ -243,7 +252,13 @@ async function startSidecar(options) {
|
|
|
243
252
|
if (runUsage) {
|
|
244
253
|
const m = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
245
254
|
m.usage = runUsage;
|
|
246
|
-
|
|
255
|
+
writeFileAtomic(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
|
|
256
|
+
// B24: cross-run spend ledger. Best-effort — appendSpend never throws, but
|
|
257
|
+
// this run's own success must never hinge on ledger bookkeeping either way.
|
|
258
|
+
try {
|
|
259
|
+
const { appendSpend } = require('../utils/spend-ledger');
|
|
260
|
+
appendSpend({ taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage });
|
|
261
|
+
} catch { /* best-effort */ }
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
if (json) {
|
|
@@ -54,18 +54,36 @@ function killPidBestEffort(pid, kill = process.kill.bind(process)) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** SIGKILL a pid, swallowing ESRCH. @returns {boolean} signal was sent */
|
|
58
|
+
function killPidHard(pid, kill = process.kill.bind(process)) {
|
|
59
|
+
if (!pid) { return false; }
|
|
60
|
+
try { kill(pid, 'SIGKILL'); return true; } catch (err) {
|
|
61
|
+
if (err.code !== 'ESRCH') {
|
|
62
|
+
logger.warn('Failed to force-kill process', { pid, error: err.message });
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
/**
|
|
58
69
|
* Wait up to graceMs for the pids to exit on their own (marker-honoring
|
|
59
70
|
* teardown), then SIGTERM any survivor. Early-exits as soon as every target
|
|
60
71
|
* is gone, so a process that honors the marker in ~2s never sees a signal.
|
|
61
72
|
*
|
|
73
|
+
* Opt-in escalation (B06): pass `escalate` to additionally wait up to
|
|
74
|
+
* `escalate.killGraceMs` after the SIGTERM for the survivor(s) to exit, then
|
|
75
|
+
* SIGKILL anyone still alive. Same REF'd poll cadence (`pollMs`) as the TERM
|
|
76
|
+
* tier. A pid that was already dead at entry, or that exits during the TERM
|
|
77
|
+
* grace window, is never escalated — escalation only applies to the set that
|
|
78
|
+
* was actually SIGTERM'd and remained alive.
|
|
79
|
+
*
|
|
62
80
|
* NOTE: the poll timer is deliberately REF'D. The CLI awaits this call and
|
|
63
81
|
* must stay alive through the grace window; callers that must not block
|
|
64
82
|
* (MCP handler) fire-and-forget the returned promise instead.
|
|
65
83
|
*
|
|
66
84
|
* @param {number|null|Array<number|null>} pids
|
|
67
|
-
* @param {{graceMs?:number, pollMs?:number, deps?:{kill?:Function, sleep?:Function}}} [opts]
|
|
68
|
-
* @returns {Promise<{killed:number[], exited:number[]}>}
|
|
85
|
+
* @param {{graceMs?:number, pollMs?:number, escalate?:{killGraceMs?:number}, deps?:{kill?:Function, sleep?:Function}}} [opts]
|
|
86
|
+
* @returns {Promise<{killed:number[], exited:number[], escalated:number[]}>}
|
|
69
87
|
*/
|
|
70
88
|
async function waitThenKill(pids, opts = {}) {
|
|
71
89
|
const graceMs = opts.graceMs !== undefined ? opts.graceMs : abortGraceMs();
|
|
@@ -82,10 +100,42 @@ async function waitThenKill(pids, opts = {}) {
|
|
|
82
100
|
remaining = remaining.filter((pid) => isAlive(pid, kill));
|
|
83
101
|
}
|
|
84
102
|
const killed = remaining.filter((pid) => killPidBestEffort(pid, kill));
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
const exited = targets.filter((pid) => !remaining.includes(pid));
|
|
104
|
+
|
|
105
|
+
let escalated = [];
|
|
106
|
+
if (opts.escalate && killed.length > 0) {
|
|
107
|
+
const killGraceMs = opts.escalate.killGraceMs !== undefined ? opts.escalate.killGraceMs : 2000;
|
|
108
|
+
const killDeadline = Date.now() + killGraceMs;
|
|
109
|
+
let stillAlive = killed.filter((pid) => isAlive(pid, kill));
|
|
110
|
+
while (stillAlive.length > 0 && Date.now() < killDeadline) {
|
|
111
|
+
await sleep(pollMs);
|
|
112
|
+
stillAlive = stillAlive.filter((pid) => isAlive(pid, kill));
|
|
113
|
+
}
|
|
114
|
+
// Anyone SIGTERM'd that isn't in the final stillAlive set exited on its
|
|
115
|
+
// own during the kill-grace window; anyone left gets SIGKILL'd here.
|
|
116
|
+
escalated = stillAlive.filter((pid) => killPidHard(pid, kill));
|
|
117
|
+
for (const pid of killed) { exited.push(pid); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return opts.escalate ? { killed, exited, escalated } : { killed, exited };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a session's Node/Go process pair is alive. Thin wrapper over
|
|
125
|
+
* isAlive — kept here (not sidecar/session-utils.js) so both liveness
|
|
126
|
+
* checks share one EPERM-aware classification. session-utils.js re-exports
|
|
127
|
+
* this for backward-compatible imports.
|
|
128
|
+
* @param {Object} metadata - Session metadata with pid and goPid
|
|
129
|
+
* @returns {'alive'|'server-dead'|'dead'}
|
|
130
|
+
*/
|
|
131
|
+
function checkSessionLiveness(metadata) {
|
|
132
|
+
if (!metadata) { return 'dead'; }
|
|
133
|
+
const nodeAlive = isAlive(metadata.pid);
|
|
134
|
+
const goAlive = isAlive(metadata.goPid);
|
|
135
|
+
|
|
136
|
+
if (nodeAlive && goAlive) { return 'alive'; }
|
|
137
|
+
if (nodeAlive && !goAlive) { return 'server-dead'; }
|
|
138
|
+
return 'dead';
|
|
89
139
|
}
|
|
90
140
|
|
|
91
|
-
module.exports = { abortGraceMs, isAlive, killPidBestEffort, waitThenKill };
|
|
141
|
+
module.exports = { abortGraceMs, isAlive, killPidBestEffort, killPidHard, waitThenKill, checkSessionLiveness };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module abort-result
|
|
3
|
+
* The abort-result document builder for `abort <taskId|--all> --json` (B21-rest).
|
|
4
|
+
* Split out of result-schema.js purely to stay under the size gate — same
|
|
5
|
+
* versioning contract (fields only ADDED within a SCHEMA_VERSION) applies here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { SCHEMA_VERSION } = require('./result-schema-version');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build an abort-result document.
|
|
14
|
+
* `ok` is true iff at least one session/leg was actually marked aborted by this
|
|
15
|
+
* call — a no-op (nothing running) is a successful call with an empty list, but
|
|
16
|
+
* a specific taskId that exists yet was not running (already terminal) reports
|
|
17
|
+
* ok:false so a scripted caller can tell "nothing happened" from "you aborted N".
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {'session'|'wave'|'all'} opts.scope
|
|
20
|
+
* @param {string|null} opts.taskId - null for scope:'all'
|
|
21
|
+
* @param {string[]} opts.aborted - ids actually marked aborted (session/wave id + any legs)
|
|
22
|
+
* @returns {object} abort document
|
|
23
|
+
*/
|
|
24
|
+
function buildAbortResult({ scope, taskId = null, aborted = [] }) {
|
|
25
|
+
return {
|
|
26
|
+
schemaVersion: SCHEMA_VERSION,
|
|
27
|
+
type: 'abort',
|
|
28
|
+
ok: aborted.length > 0 || scope === 'all',
|
|
29
|
+
scope,
|
|
30
|
+
taskId,
|
|
31
|
+
aborted,
|
|
32
|
+
count: aborted.length,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { buildAbortResult };
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { validateApiKey, validateOpenRouterKey, VALIDATION_ENDPOINTS } = require('./api-key-validation');
|
|
8
|
-
const { getCompatEnv } = require('./env-compat');
|
|
9
8
|
|
|
10
9
|
/** Maps provider IDs to environment variable names */
|
|
11
10
|
const PROVIDER_ENV_MAP = {
|
|
@@ -23,7 +22,7 @@ const LEGACY_KEY_NAMES = {
|
|
|
23
22
|
|
|
24
23
|
/** Get the path to the .env file */
|
|
25
24
|
function getEnvPath() {
|
|
26
|
-
const envDir =
|
|
25
|
+
const envDir = process.env.AMICUS_ENV_DIR;
|
|
27
26
|
if (envDir) {
|
|
28
27
|
const resolved = path.resolve(envDir);
|
|
29
28
|
if (resolved.includes('\0')) {
|
|
@@ -32,17 +31,7 @@ function getEnvPath() {
|
|
|
32
31
|
return path.join(resolved, '.env');
|
|
33
32
|
}
|
|
34
33
|
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
35
|
-
|
|
36
|
-
// DEPRECATED(amicus-shim): fall back to the legacy ~/.config/sidecar/.env if it
|
|
37
|
-
// exists and the new one does not, so pre-rebrand installs keep reading/writing
|
|
38
|
-
// their existing keys. Remove in a future revision — see docs/SHIMS.md.
|
|
39
|
-
if (!fs.existsSync(amicusEnvPath)) {
|
|
40
|
-
const legacyEnvPath = path.join(homeDir, '.config', 'sidecar', '.env');
|
|
41
|
-
if (fs.existsSync(legacyEnvPath)) {
|
|
42
|
-
return legacyEnvPath;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return amicusEnvPath;
|
|
34
|
+
return path.join(homeDir, '.config', 'amicus', '.env');
|
|
46
35
|
}
|
|
47
36
|
|
|
48
37
|
/** Parse a .env file into a key-value map (comments/blanks excluded) */
|