amicus 1.9.1 → 2.0.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 +138 -0
- package/README.md +40 -170
- package/bin/amicus.js +14 -20
- package/commands/council.md +3 -1
- 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 +24 -23
- package/skills/sidecar/SKILL.md +3 -3
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-run.js +4 -4
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli.js +35 -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 +132 -108
- package/src/mcp-tools.js +27 -3
- 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 +12 -5
- 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 +19 -4
- 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/api-key-store.js +2 -13
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -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/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +29 -5
- 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.js +8 -2
- 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
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
|
|
|
@@ -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
|
|
|
@@ -179,7 +193,7 @@ async function resumeSidecar(options) {
|
|
|
179
193
|
const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
|
|
180
194
|
result = await runHeadless(
|
|
181
195
|
metadata.model, resumePrompt, userMessage,
|
|
182
|
-
taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
|
|
196
|
+
taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
|
|
183
197
|
);
|
|
184
198
|
summary = result.summary || '## Sidecar Results: No Output\n\nResumed session completed without summary.';
|
|
185
199
|
|
|
@@ -196,7 +210,8 @@ async function resumeSidecar(options) {
|
|
|
196
210
|
isResume: true,
|
|
197
211
|
conversation: existingConversation,
|
|
198
212
|
opencodeSessionId: metadata.opencodeSessionId,
|
|
199
|
-
mcp: mcpServers
|
|
213
|
+
mcp: mcpServers,
|
|
214
|
+
foldNonce
|
|
200
215
|
}
|
|
201
216
|
);
|
|
202
217
|
summary = result.summary || '';
|
|
@@ -216,7 +231,7 @@ async function resumeSidecar(options) {
|
|
|
216
231
|
updatedMetadata.status = 'error';
|
|
217
232
|
updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
218
233
|
updatedMetadata.completedAt = new Date().toISOString();
|
|
219
|
-
|
|
234
|
+
writeFileAtomic(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
|
|
220
235
|
logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
|
|
221
236
|
} else {
|
|
222
237
|
finalizeSession(sessionDir, summary, project, updatedMetadata, { status: terminal.status });
|
|
@@ -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 };
|
|
@@ -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) */
|
package/src/utils/config.js
CHANGED
|
@@ -9,15 +9,17 @@ const fs = require('fs');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
11
|
const { applyDirectApiFallback, autoRepairAlias } = require('./alias-resolver');
|
|
12
|
-
const { getCompatEnv } = require('./env-compat');
|
|
13
12
|
|
|
14
13
|
/** Default model alias map — derived from the curated-models single source (F5) */
|
|
15
14
|
const { toDefaultAliases } = require('./curated-models');
|
|
16
15
|
const DEFAULT_ALIASES = toDefaultAliases();
|
|
17
16
|
|
|
17
|
+
/** Built-in council benches (B23) — consulted only when a name is absent from user config. */
|
|
18
|
+
const { resolveBuiltinCouncil } = require('./council-presets');
|
|
19
|
+
|
|
18
20
|
/** @returns {string} Config directory path */
|
|
19
21
|
function getConfigDir() {
|
|
20
|
-
const override =
|
|
22
|
+
const override = process.env.AMICUS_CONFIG_DIR;
|
|
21
23
|
if (override) {
|
|
22
24
|
const resolved = path.resolve(override);
|
|
23
25
|
if (resolved.includes('\0')) {
|
|
@@ -26,45 +28,7 @@ function getConfigDir() {
|
|
|
26
28
|
return resolved;
|
|
27
29
|
}
|
|
28
30
|
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
29
|
-
|
|
30
|
-
// DEPRECATED(amicus-shim): fall back to the legacy ~/.config/sidecar dir if it
|
|
31
|
-
// exists and the new one does not, so pre-rebrand credentials keep working.
|
|
32
|
-
// Remove in a future revision — see docs/SHIMS.md.
|
|
33
|
-
if (!fs.existsSync(amicusDir)) {
|
|
34
|
-
const legacyDir = path.join(homeDir, '.config', 'sidecar');
|
|
35
|
-
if (fs.existsSync(legacyDir)) {
|
|
36
|
-
return legacyDir;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return amicusDir;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* One-time, non-destructive migration of the legacy ~/.config/sidecar config
|
|
44
|
-
* directory onto the canonical ~/.config/amicus. Copies (does not move), so the
|
|
45
|
-
* legacy dir is left intact as a backup. This collapses the two-dir split that
|
|
46
|
-
* let getConfigDir() flip between them and orphan data: once ~/.config/amicus
|
|
47
|
-
* exists it always wins. No-op when amicus already exists, when there is no
|
|
48
|
-
* legacy dir, or when a CONFIG_DIR override is set. Best-effort — returns a
|
|
49
|
-
* result object and never throws. Call once at startup, before any config read.
|
|
50
|
-
*
|
|
51
|
-
* @param {{home?: string}} [opts]
|
|
52
|
-
* @returns {{migrated: boolean, from?: string, to?: string, reason?: string, error?: string}}
|
|
53
|
-
*/
|
|
54
|
-
function migrateLegacyConfigDir(opts = {}) {
|
|
55
|
-
if (getCompatEnv('CONFIG_DIR')) { return { migrated: false, reason: 'override-set' }; }
|
|
56
|
-
const home = opts.home || process.env.HOME || process.env.USERPROFILE;
|
|
57
|
-
if (!home) { return { migrated: false, reason: 'no-home' }; }
|
|
58
|
-
const amicusDir = path.join(home, '.config', 'amicus');
|
|
59
|
-
const legacyDir = path.join(home, '.config', 'sidecar');
|
|
60
|
-
try {
|
|
61
|
-
if (fs.existsSync(amicusDir)) { return { migrated: false, reason: 'amicus-exists' }; }
|
|
62
|
-
if (!fs.existsSync(legacyDir)) { return { migrated: false, reason: 'no-legacy' }; }
|
|
63
|
-
fs.cpSync(legacyDir, amicusDir, { recursive: true });
|
|
64
|
-
return { migrated: true, from: legacyDir, to: amicusDir };
|
|
65
|
-
} catch (err) {
|
|
66
|
-
return { migrated: false, reason: 'error', error: err.message };
|
|
67
|
-
}
|
|
31
|
+
return path.join(homeDir, '.config', 'amicus');
|
|
68
32
|
}
|
|
69
33
|
|
|
70
34
|
/** @returns {string} Full path to config.json */
|
|
@@ -312,6 +276,24 @@ function getCouncil(name) {
|
|
|
312
276
|
return getCouncils()[name] || null;
|
|
313
277
|
}
|
|
314
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Look up a council's raw member list, checking user config FIRST and the
|
|
281
|
+
* built-in benches (free/budget/frontier — src/utils/council-presets.js)
|
|
282
|
+
* only when the name is absent from user config. User config always shadows
|
|
283
|
+
* a same-named built-in — this matches the pre-existing last-write-wins
|
|
284
|
+
* posture the wizard's `councils.free` seeding already relied on.
|
|
285
|
+
* @param {string} name
|
|
286
|
+
* @param {Array<{id:string}>} [catalog] needed only to resolve the dynamic 'free' bench
|
|
287
|
+
* @returns {{members:string[]|null, builtin:boolean}}
|
|
288
|
+
*/
|
|
289
|
+
function getCouncilWithSource(name, catalog = []) {
|
|
290
|
+
const userMembers = getCouncil(name);
|
|
291
|
+
if (userMembers) { return { members: userMembers, builtin: false }; }
|
|
292
|
+
const builtinMembers = resolveBuiltinCouncil(name, catalog);
|
|
293
|
+
if (builtinMembers) { return { members: builtinMembers, builtin: true }; }
|
|
294
|
+
return { members: null, builtin: false };
|
|
295
|
+
}
|
|
296
|
+
|
|
315
297
|
/**
|
|
316
298
|
* Expand a saved council into a runnable members list, degrading gracefully.
|
|
317
299
|
* Each member is resolved to its full model id (alias → id via effective
|
|
@@ -320,12 +302,17 @@ function getCouncil(name) {
|
|
|
320
302
|
* warning rather than fail-fast-aborting the whole wave. The catalog check is
|
|
321
303
|
* skipped when the catalog is empty (offline). Returns members RAW (alias or
|
|
322
304
|
* id) — leg-time validation resolves them again.
|
|
305
|
+
*
|
|
306
|
+
* Resolution order: user config (`config.councils`) is checked first; when
|
|
307
|
+
* `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
|
|
308
|
+
* are consulted (src/utils/council-presets.js). A user-saved council always
|
|
309
|
+
* shadows a built-in of the same name.
|
|
323
310
|
* @param {string} name
|
|
324
311
|
* @param {Array<{id:string}>} [catalog]
|
|
325
312
|
* @returns {{models:string[], dropped:string[]} | {error:string}}
|
|
326
313
|
*/
|
|
327
314
|
function resolveCouncilMembers(name, catalog = []) {
|
|
328
|
-
const members =
|
|
315
|
+
const { members } = getCouncilWithSource(name, catalog);
|
|
329
316
|
if (!members) {
|
|
330
317
|
return { error: `Unknown council '${name}'. Run 'amicus setup' to create one.` };
|
|
331
318
|
}
|
|
@@ -354,7 +341,6 @@ function resolveCouncilMembers(name, catalog = []) {
|
|
|
354
341
|
|
|
355
342
|
module.exports = {
|
|
356
343
|
getConfigDir,
|
|
357
|
-
migrateLegacyConfigDir,
|
|
358
344
|
getConfigPath,
|
|
359
345
|
loadConfig,
|
|
360
346
|
saveConfig,
|
|
@@ -370,5 +356,6 @@ module.exports = {
|
|
|
370
356
|
buildProviderModels,
|
|
371
357
|
getCouncils,
|
|
372
358
|
getCouncil,
|
|
359
|
+
getCouncilWithSource,
|
|
373
360
|
resolveCouncilMembers,
|
|
374
361
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in council benches (B23).
|
|
3
|
+
*
|
|
4
|
+
* `resolveCouncilMembers` (src/utils/config.js) consults this table ONLY when
|
|
5
|
+
* the requested name is absent from user config (`config.councils`) — user
|
|
6
|
+
* config always shadows a built-in of the same name. This preserves today's
|
|
7
|
+
* behavior for the wizard-seeded `councils.free` (src/sidecar/setup.js
|
|
8
|
+
* seedFreeCouncil): once seeded, the user's `free` list wins over the
|
|
9
|
+
* built-in dynamic free bench below.
|
|
10
|
+
*
|
|
11
|
+
* Two shapes:
|
|
12
|
+
* - 'free' is DYNAMIC: resolved at use time from the live catalog via
|
|
13
|
+
* suggestFreeCouncil (one :free model per vendor), falling back to the
|
|
14
|
+
* offline PINNED_FREE_MODELS when the catalog has no free rows. This
|
|
15
|
+
* mirrors the wizard's own free-council derivation so the built-in and
|
|
16
|
+
* the wizard-seeded version pick the same kind of members.
|
|
17
|
+
* - 'budget' and 'frontier' are STATIC: three DEFAULT_ALIASES entries
|
|
18
|
+
* (src/utils/curated-models.js) each, chosen by catalog pricing at
|
|
19
|
+
* implementation time (see docs/CHANGELOG or the task report for the
|
|
20
|
+
* pricing evidence). Alias-based so `amicus models --check` drift
|
|
21
|
+
* tooling and normal alias resolution keep them healthy for free —
|
|
22
|
+
* no raw model ids are hardcoded here.
|
|
23
|
+
*/
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const { suggestFreeCouncil, PINNED_FREE_MODELS } = require('./free-models');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Budget bench: three cheapest DEFAULT_ALIASES entries, one per vendor
|
|
30
|
+
* family, verified against the cached catalog (~/.config/amicus/model-catalog.json)
|
|
31
|
+
* on 2026-07-02 (prices are $/token, prompt+completion):
|
|
32
|
+
* minimax openrouter/minimax/minimax-m2.7 $0.00000018 / $0.00000072
|
|
33
|
+
* qwen-coder openrouter/qwen/qwen3-coder-next $0.00000011 / $0.0000008
|
|
34
|
+
* deepseek openrouter/deepseek/deepseek-v4-pro $0.000000435 / $0.00000087
|
|
35
|
+
* These are the three lowest total (prompt+completion) prices in
|
|
36
|
+
* DEFAULT_ALIASES, and each is a distinct vendor family (MiniMax / Qwen /
|
|
37
|
+
* DeepSeek) — the qwen-flash entry (also cheap) was skipped to keep vendor
|
|
38
|
+
* diversity across the bench.
|
|
39
|
+
*/
|
|
40
|
+
const BUDGET_ALIASES = ['minimax', 'qwen-coder', 'deepseek'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Frontier bench: three premium-flagship DEFAULT_ALIASES entries, one per
|
|
44
|
+
* vendor family, verified against the same catalog snapshot:
|
|
45
|
+
* gpt-pro openrouter/openai/gpt-5.5-pro $0.00003 / $0.00018
|
|
46
|
+
* opus openrouter/anthropic/claude-opus-4.8 $0.000005 / $0.000025
|
|
47
|
+
* gemini-pro openrouter/google/gemini-3.1-pro-preview $0.000002 / $0.000012
|
|
48
|
+
* These are the three highest total (prompt+completion) prices in
|
|
49
|
+
* DEFAULT_ALIASES that are also each a distinct vendor family (OpenAI /
|
|
50
|
+
* Anthropic / Google) — `gpt` and `codex` (also OpenAI) and `claude`/`sonnet`
|
|
51
|
+
* (also Anthropic) were skipped as same-family duplicates of the pick above.
|
|
52
|
+
*/
|
|
53
|
+
const FRONTIER_ALIASES = ['gpt-pro', 'opus', 'gemini-pro'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {Array} catalog live model-catalog rows (for the dynamic free bench)
|
|
57
|
+
* @returns {string[]} council members (aliases or full ids), possibly empty
|
|
58
|
+
*/
|
|
59
|
+
function resolveFreeBench(catalog) {
|
|
60
|
+
const picks = suggestFreeCouncil(Array.isArray(catalog) ? catalog : []);
|
|
61
|
+
if (picks.length > 0) { return picks.map(p => p.id); }
|
|
62
|
+
return [...PINNED_FREE_MODELS];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} name
|
|
67
|
+
* @param {Array} [catalog] live model-catalog rows, needed only for 'free'
|
|
68
|
+
* @returns {string[]|null} resolved built-in members, or null if `name` is not a built-in
|
|
69
|
+
*/
|
|
70
|
+
function resolveBuiltinCouncil(name, catalog = []) {
|
|
71
|
+
if (name === 'free') { return resolveFreeBench(catalog); }
|
|
72
|
+
if (name === 'budget') { return [...BUDGET_ALIASES]; }
|
|
73
|
+
if (name === 'frontier') { return [...FRONTIER_ALIASES]; }
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @returns {string[]} built-in bench names, in resolution-doc order */
|
|
78
|
+
function listBuiltinCouncilNames() {
|
|
79
|
+
return ['free', 'budget', 'frontier'];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
BUDGET_ALIASES,
|
|
84
|
+
FRONTIER_ALIASES,
|
|
85
|
+
resolveBuiltinCouncil,
|
|
86
|
+
listBuiltinCouncilNames,
|
|
87
|
+
};
|