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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cli-preflight
|
|
3
|
+
* Tiny shared preflight guards used by more than one CLI run handler
|
|
4
|
+
* (start/resume/continue/fanout), split out so each handler file can stay
|
|
5
|
+
* under the size gate without duplicating the same few lines.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { failJson, ERROR_CODES } = require('./error-doc');
|
|
11
|
+
const { validateTaskId } = require('./validators');
|
|
12
|
+
|
|
13
|
+
/** Shared --json requires --no-ui gate. Exits (never returns) on violation. */
|
|
14
|
+
function requireNoUiForJson(args, useJson) {
|
|
15
|
+
if (args.json && !args['no-ui']) {
|
|
16
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shared task-id presence + format check. Exits (never returns) on violation.
|
|
22
|
+
* @param {object} args - parsed CLI args (positional task id at args._[1])
|
|
23
|
+
* @param {boolean} useJson
|
|
24
|
+
* @param {string} commandLabel - e.g. 'resume', 'continue'
|
|
25
|
+
* @param {string} [usage] - appended to the missing-id message
|
|
26
|
+
* @returns {string} the validated task id
|
|
27
|
+
*/
|
|
28
|
+
function requireValidTaskId(args, useJson, commandLabel, usage) {
|
|
29
|
+
const taskId = args._[1];
|
|
30
|
+
if (!taskId) {
|
|
31
|
+
process.exit(failJson(useJson, {
|
|
32
|
+
code: ERROR_CODES.BAD_SESSION,
|
|
33
|
+
message: `Error: task_id is required for ${commandLabel}${usage ? `\n${usage}` : ''}`,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
const check = validateTaskId(taskId);
|
|
37
|
+
if (!check.valid) {
|
|
38
|
+
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_SESSION, message: check.error }));
|
|
39
|
+
}
|
|
40
|
+
return taskId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { requireNoUiForJson, requireValidTaskId };
|
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
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module doctor-mcp-checks
|
|
3
|
+
* B14/Task 4.3: the two MCP-registration doctor checks ('mcp' and
|
|
4
|
+
* 'mcp-legacy'), split out of src/cli-handlers-doctor.js to keep that file
|
|
5
|
+
* under the 300-line size gate (mirrors how session-index-tmp-sweep.js holds
|
|
6
|
+
* the B15 sweep's evaluate* composer — src/cli-handlers-doctor.js just wraps
|
|
7
|
+
* these in guard() the same way).
|
|
8
|
+
*
|
|
9
|
+
* 'mcp' (evaluateMcpRegistration): PRIMARY signal is
|
|
10
|
+
* d.hasAmicusRegistration() — a RAW (unstripped) read of the same Claude
|
|
11
|
+
* Code sources discoverClaudeCodeMcps reads. discoverClaudeCodeMcps always
|
|
12
|
+
* strips every 'amicus'/'sidecar'-shaped entry as its own recursive-spawn
|
|
13
|
+
* guard (src/utils/mcp-self-identity.js), so testing `code.amicus` here
|
|
14
|
+
* would ALWAYS be false — that was the B14 false-negative. Cowork/Desktop
|
|
15
|
+
* discovery (d.discoverCoworkMcps) does not strip and stays a bonus signal.
|
|
16
|
+
*
|
|
17
|
+
* 'mcp-legacy' (evaluateLegacyMcpEntry): unchanged logic, moved verbatim.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const HINTS = require('./remediation-hints');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{hasAmicusRegistration: () => boolean, discoverCoworkMcps: () => object|null}} d
|
|
26
|
+
*/
|
|
27
|
+
function evaluateMcpRegistration(d) {
|
|
28
|
+
const id = 'mcp'; const name = 'MCP registration';
|
|
29
|
+
const inCode = !!d.hasAmicusRegistration();
|
|
30
|
+
const cowork = d.discoverCoworkMcps();
|
|
31
|
+
const inCowork = !!(cowork && cowork.amicus);
|
|
32
|
+
// Primary signal: Claude Code MCP registration. Cowork/Desktop is reported as bonus only.
|
|
33
|
+
if (!inCode) {
|
|
34
|
+
return { id, name, status: 'warn', message: 'not registered in Claude Code', hint: `${HINTS.reinstall} (or install the amicus plugin)` };
|
|
35
|
+
}
|
|
36
|
+
const extra = inCowork ? ', Cowork/Desktop' : '';
|
|
37
|
+
return { id, name, status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Duplicate legacy 'sidecar' MCP registration (same server twice — doubles
|
|
42
|
+
* the client-visible tool list). Detection reads the raw config files via
|
|
43
|
+
* legacy-mcp-migration: mcp-discovery can't see it (it strips 'sidecar' as
|
|
44
|
+
* its own recursion guard). --fix removes only identical-in-effect twins.
|
|
45
|
+
* @param {{inspectLegacyMcpEntries: () => Array, fix?: boolean, migrateLegacyMcpEntries: () => Array}} d
|
|
46
|
+
*/
|
|
47
|
+
function evaluateLegacyMcpEntry(d) {
|
|
48
|
+
const id = 'mcp-legacy'; const name = 'Legacy sidecar MCP entry';
|
|
49
|
+
const entries = d.inspectLegacyMcpEntries() || [];
|
|
50
|
+
const dupes = entries.filter(e => e.status === 'removable');
|
|
51
|
+
const custom = entries.filter(e => e.status === 'customized');
|
|
52
|
+
// An unreadable config is neither "no problem" nor a duplicate we can act
|
|
53
|
+
// on — reporting it as ok/'none' would hide a config doctor (and --fix)
|
|
54
|
+
// could not actually inspect. Always surface it, even alongside dupes.
|
|
55
|
+
const unreadable = entries.filter(e => e.status === 'unreadable');
|
|
56
|
+
const unreadableNote = unreadable.length
|
|
57
|
+
? `${unreadable.map(e => e.target).join(', ')} config unreadable — skipped`
|
|
58
|
+
: null;
|
|
59
|
+
if (dupes.length === 0) {
|
|
60
|
+
if (unreadableNote) {
|
|
61
|
+
const suffix = custom.length ? `; custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone` : '';
|
|
62
|
+
return { id, name, status: 'warn', message: `${unreadableNote}${suffix}`, hint: null };
|
|
63
|
+
}
|
|
64
|
+
const message = custom.length
|
|
65
|
+
? `custom 'sidecar' entry in ${custom.map(e => e.target).join(', ')} — left alone`
|
|
66
|
+
: 'none';
|
|
67
|
+
return { id, name, status: 'ok', message, hint: null };
|
|
68
|
+
}
|
|
69
|
+
if (d.fix) {
|
|
70
|
+
const removed = (d.migrateLegacyMcpEntries() || []).filter(r => r.result === 'removed');
|
|
71
|
+
if (removed.length >= dupes.length) {
|
|
72
|
+
const message = `removed legacy entry from: ${removed.map(r => r.target).join(', ')}`;
|
|
73
|
+
return unreadableNote
|
|
74
|
+
? { id, name, status: 'warn', message: `${message}; ${unreadableNote}`, hint: HINTS.removeLegacySidecar }
|
|
75
|
+
: { id, name, status: 'ok', message, hint: null };
|
|
76
|
+
}
|
|
77
|
+
const message = `removed ${removed.length}/${dupes.length} duplicate(s) — could not update every config`;
|
|
78
|
+
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
|
|
79
|
+
}
|
|
80
|
+
const message = `duplicate 'sidecar' entry in ${dupes.map(e => e.target).join(', ')} — doubles the MCP tool list`;
|
|
81
|
+
return { id, name, status: 'warn', message: unreadableNote ? `${message}; ${unreadableNote}` : message, hint: HINTS.removeLegacySidecar };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { evaluateMcpRegistration, evaluateLegacyMcpEntry };
|
package/src/utils/env-loader.js
CHANGED
|
@@ -16,11 +16,10 @@ const { readAuthJsonKeys } = require('./auth-json');
|
|
|
16
16
|
* Sources (in priority order):
|
|
17
17
|
* 1. process.env - already set, never overwritten
|
|
18
18
|
* 2. ~/.config/amicus/.env - user-configured via `amicus setup`
|
|
19
|
-
* (DEPRECATED(amicus-shim): falls back to ~/.config/sidecar/.env if the amicus .env absent)
|
|
20
19
|
* 3. ~/.local/share/opencode/auth.json - OpenCode SDK fallback
|
|
21
20
|
*/
|
|
22
21
|
function loadCredentials() {
|
|
23
|
-
// Step 1: Load from
|
|
22
|
+
// Step 1: Load from amicus .env file
|
|
24
23
|
const fileEntries = loadEnvEntries();
|
|
25
24
|
for (const [, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
|
|
26
25
|
if (!process.env[envVar]) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold marker construction/parsing helpers — shared by prompt-builder.js
|
|
3
|
+
* (instructs the model), headless.js (writes + detects), electron/fold.js
|
|
4
|
+
* (writes), and src/sidecar/resume.js (re-derives the nonce from a saved
|
|
5
|
+
* prompt on resume). Centralized here so the marker CONTRACT lives in one
|
|
6
|
+
* place instead of being duplicated string-literal by string-literal.
|
|
7
|
+
*
|
|
8
|
+
* #BL-7 residual: the marker used to be the static string `[SIDECAR_FOLD]`,
|
|
9
|
+
* so model output that genuinely ends with a bare marker (echoing these
|
|
10
|
+
* instructions, a prior sidecar summary, or scraped content) could force a
|
|
11
|
+
* premature fold. A per-run nonce closes that gap — only the exact nonce
|
|
12
|
+
* generated for THIS run completes THIS run.
|
|
13
|
+
*/
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
/** The fixed, public prefix. Intentionally still `[SIDECAR_FOLD` as a
|
|
17
|
+
* substring — anything that greps for the OLD literal string still finds
|
|
18
|
+
* the new nonced marker; it just no longer matches an ANCHORED bare-bracket
|
|
19
|
+
* string (`[SIDECAR_FOLD]`) because a real marker now always carries a
|
|
20
|
+
* `:<nonce>` suffix before the closing bracket. */
|
|
21
|
+
const FOLD_MARKER_PREFIX = 'SIDECAR_FOLD';
|
|
22
|
+
|
|
23
|
+
/** Generate a fresh per-run nonce. 16 hex chars (8 random bytes) — comfortably
|
|
24
|
+
* above the brief's 12+ hex char floor, cheap to embed in prompts. */
|
|
25
|
+
function generateFoldNonce() {
|
|
26
|
+
return crypto.randomBytes(8).toString('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the full marker string for a given nonce: `[SIDECAR_FOLD:<nonce>]`.
|
|
31
|
+
* @param {string} nonce
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function buildFoldMarker(nonce) {
|
|
35
|
+
return `[${FOLD_MARKER_PREFIX}:${nonce}]`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Escape a string for safe embedding inside a RegExp source. */
|
|
39
|
+
function escapeRegExp(str) {
|
|
40
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a RegExp that matches `buildFoldMarker(nonce)` as the FINAL
|
|
45
|
+
* non-empty line of a string (see headless.js findTrailingFoldMarker for the
|
|
46
|
+
* consuming semantics). Exported so headless.js doesn't hand-roll the same
|
|
47
|
+
* escaping logic.
|
|
48
|
+
* @param {string} nonce
|
|
49
|
+
* @returns {RegExp}
|
|
50
|
+
*/
|
|
51
|
+
function trailingFoldMarkerRegex(nonce) {
|
|
52
|
+
const escaped = escapeRegExp(buildFoldMarker(nonce));
|
|
53
|
+
return new RegExp(`^[^\\S\\r\\n]*${escaped}[^\\S\\r\\n]*$(?![\\s\\S]*\\S)`, 'm');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Recover the nonce embedded in a previously-built system prompt (resume.js:
|
|
58
|
+
* a resumed session re-sends the ORIGINAL prompt text — which already
|
|
59
|
+
* instructs the model with the nonce baked in at initial `start`/`continue`
|
|
60
|
+
* time — rather than building a fresh one via buildPrompts). Matches the
|
|
61
|
+
* FIRST occurrence of the marker anywhere in the text (the prompt's own
|
|
62
|
+
* instruction line), not a final-line match — this is prompt text, not
|
|
63
|
+
* model output.
|
|
64
|
+
* @param {string} text
|
|
65
|
+
* @returns {string|null} the nonce, or null if no marker is present
|
|
66
|
+
*/
|
|
67
|
+
function extractNonceFromText(text) {
|
|
68
|
+
if (!text) { return null; }
|
|
69
|
+
const m = new RegExp(`\\[${FOLD_MARKER_PREFIX}:([0-9a-f]+)\\]`).exec(text);
|
|
70
|
+
return m ? m[1] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
FOLD_MARKER_PREFIX,
|
|
75
|
+
generateFoldNonce,
|
|
76
|
+
buildFoldMarker,
|
|
77
|
+
trailingFoldMarkerRegex,
|
|
78
|
+
extractNonceFromText,
|
|
79
|
+
};
|
|
@@ -9,16 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Timeout priority (highest to lowest):
|
|
11
11
|
* 1. Per-mode env var (AMICUS_IDLE_TIMEOUT_HEADLESS, etc.) in minutes
|
|
12
|
-
*
|
|
13
|
-
* 2. Blanket env var AMICUS_IDLE_TIMEOUT / SIDECAR_IDLE_TIMEOUT in minutes
|
|
12
|
+
* 2. Blanket env var AMICUS_IDLE_TIMEOUT in minutes
|
|
14
13
|
* 3. Constructor option `timeout` in milliseconds
|
|
15
14
|
* 4. Mode default (headless=15m, interactive=60m, server=30m)
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
17
|
'use strict';
|
|
19
18
|
|
|
20
|
-
const { getCompatEnv } = require('./env-compat');
|
|
21
|
-
|
|
22
19
|
/** @type {Object.<string, number>} Default timeouts per mode in milliseconds */
|
|
23
20
|
const MODE_TIMEOUTS = {
|
|
24
21
|
headless: 15 * 60 * 1000,
|
|
@@ -26,11 +23,11 @@ const MODE_TIMEOUTS = {
|
|
|
26
23
|
server: 30 * 60 * 1000,
|
|
27
24
|
};
|
|
28
25
|
|
|
29
|
-
/** @type {Object.<string, string>} Per-mode env
|
|
26
|
+
/** @type {Object.<string, string>} Per-mode env var names */
|
|
30
27
|
const MODE_ENV_MAP = {
|
|
31
|
-
headless: '
|
|
32
|
-
interactive: '
|
|
33
|
-
server: '
|
|
28
|
+
headless: 'AMICUS_IDLE_TIMEOUT_HEADLESS',
|
|
29
|
+
interactive: 'AMICUS_IDLE_TIMEOUT_INTERACTIVE',
|
|
30
|
+
server: 'AMICUS_IDLE_TIMEOUT_SERVER',
|
|
34
31
|
};
|
|
35
32
|
|
|
36
33
|
/**
|
|
@@ -41,16 +38,16 @@ const MODE_ENV_MAP = {
|
|
|
41
38
|
* @returns {number} Effective timeout in ms, or Infinity if disabled
|
|
42
39
|
*/
|
|
43
40
|
function resolveTimeout(mode, optionTimeout) {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
const modeEnv =
|
|
41
|
+
const modeEnvName = MODE_ENV_MAP[mode];
|
|
42
|
+
if (modeEnvName !== undefined) {
|
|
43
|
+
const modeEnv = process.env[modeEnvName];
|
|
47
44
|
if (modeEnv !== undefined) {
|
|
48
45
|
const mins = Number(modeEnv);
|
|
49
46
|
return mins === 0 ? Infinity : mins * 60 * 1000;
|
|
50
47
|
}
|
|
51
48
|
}
|
|
52
49
|
|
|
53
|
-
const blanket =
|
|
50
|
+
const blanket = process.env.AMICUS_IDLE_TIMEOUT;
|
|
54
51
|
if (blanket !== undefined) {
|
|
55
52
|
const mins = Number(blanket);
|
|
56
53
|
return mins === 0 ? Infinity : mins * 60 * 1000;
|
|
@@ -124,4 +124,55 @@ function validateStartInputs(input) {
|
|
|
124
124
|
return { valid: true, resolvedModel: resolved };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Levenshtein edit distance between two strings (insertions, deletions,
|
|
129
|
+
* substitutions, each cost 1). Hand-rolled — no runtime dependency added,
|
|
130
|
+
* since fast-levenshtein is only a dev-time transitive and runtime deps are
|
|
131
|
+
* locked for this project.
|
|
132
|
+
* @param {string} a
|
|
133
|
+
* @param {string} b
|
|
134
|
+
* @returns {number}
|
|
135
|
+
*/
|
|
136
|
+
function levenshteinDistance(a, b) {
|
|
137
|
+
const m = a.length;
|
|
138
|
+
const n = b.length;
|
|
139
|
+
if (m === 0) { return n; }
|
|
140
|
+
if (n === 0) { return m; }
|
|
141
|
+
|
|
142
|
+
// Single rolling row (O(min(m,n)) space) rather than a full m×n matrix —
|
|
143
|
+
// plenty for CLI command names, which are always short.
|
|
144
|
+
let prevRow = Array.from({ length: n + 1 }, (_, j) => j);
|
|
145
|
+
for (let i = 1; i <= m; i++) {
|
|
146
|
+
const currRow = [i];
|
|
147
|
+
for (let j = 1; j <= n; j++) {
|
|
148
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
149
|
+
currRow[j] = Math.min(
|
|
150
|
+
prevRow[j] + 1, // deletion
|
|
151
|
+
currRow[j - 1] + 1, // insertion
|
|
152
|
+
prevRow[j - 1] + cost // substitution
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
prevRow = currRow;
|
|
156
|
+
}
|
|
157
|
+
return prevRow[n];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Suggest known commands close to an unrecognized one ("did you mean").
|
|
162
|
+
* @param {string} input - the unrecognized token the user typed
|
|
163
|
+
* @param {string[]} candidates - known command names
|
|
164
|
+
* @param {number} [maxDistance=2] - inclusive distance cap
|
|
165
|
+
* @param {number} [maxSuggestions=3]
|
|
166
|
+
* @returns {string[]} candidates within maxDistance, closest first, capped
|
|
167
|
+
*/
|
|
168
|
+
function suggestCommand(input, candidates, maxDistance = 2, maxSuggestions = 3) {
|
|
169
|
+
if (!input) { return []; }
|
|
170
|
+
return candidates
|
|
171
|
+
.map(c => ({ c, distance: levenshteinDistance(input.toLowerCase(), c.toLowerCase()) }))
|
|
172
|
+
.filter(({ distance }) => distance <= maxDistance)
|
|
173
|
+
.sort((a, b) => a.distance - b.distance)
|
|
174
|
+
.slice(0, maxSuggestions)
|
|
175
|
+
.map(({ c }) => c);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { validateStartInputs, findSimilar, levenshteinDistance, suggestCommand };
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
|
|
13
13
|
// `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
|
|
14
14
|
// interactive Electron flow that must never be force-exited).
|
|
15
|
-
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
|
|
15
|
+
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor', 'spend' /* local-only: no OpenCode server, no stray handles */]);
|
|
16
16
|
|
|
17
17
|
/** @param {string} command @returns {boolean} */
|
|
18
18
|
function isOneShotCommand(command) {
|