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.
Files changed (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +200 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +19 -107
  5. package/commands/council.md +7 -3
  6. package/electron/fold.js +10 -1
  7. package/electron/ipc-setup.js +10 -15
  8. package/electron/main.js +21 -16
  9. package/electron/preload-setup.js +0 -1
  10. package/electron/setup-ui-council.js +64 -10
  11. package/electron/setup-ui-styles.js +34 -3
  12. package/electron/setup-ui.js +44 -12
  13. package/package.json +2 -5
  14. package/skills/second-opinion/MODEL-NOTES.md +2 -2
  15. package/skills/second-opinion/SKILL.md +30 -28
  16. package/skills/sidecar/SKILL.md +20 -17
  17. package/src/cli-handlers-abort.js +244 -0
  18. package/src/cli-handlers-council.js +101 -1
  19. package/src/cli-handlers-doctor.js +20 -53
  20. package/src/cli-handlers-resume-continue.js +103 -0
  21. package/src/cli-handlers-run.js +9 -8
  22. package/src/cli-handlers-spend.js +198 -0
  23. package/src/cli-handlers.js +5 -120
  24. package/src/cli.js +55 -0
  25. package/src/council/presets-cli.js +141 -0
  26. package/src/headless.js +146 -38
  27. package/src/index.js +1 -9
  28. package/src/mcp-server.js +140 -113
  29. package/src/mcp-tools.js +58 -24
  30. package/src/mcp-wait.js +8 -5
  31. package/src/opencode-client.js +33 -10
  32. package/src/prompt-builder.js +32 -11
  33. package/src/session-manager.js +7 -14
  34. package/src/sidecar/continue.js +34 -12
  35. package/src/sidecar/conversation-mirror.js +22 -1
  36. package/src/sidecar/crash-handler.js +2 -1
  37. package/src/sidecar/fanout-leg.js +12 -3
  38. package/src/sidecar/fanout.js +27 -10
  39. package/src/sidecar/interactive-process.js +6 -17
  40. package/src/sidecar/interactive.js +5 -6
  41. package/src/sidecar/models.js +33 -4
  42. package/src/sidecar/progress.js +2 -1
  43. package/src/sidecar/read.js +4 -6
  44. package/src/sidecar/resume.js +41 -11
  45. package/src/sidecar/session-finalize.js +2 -1
  46. package/src/sidecar/session-utils.js +13 -35
  47. package/src/sidecar/setup-window.js +2 -3
  48. package/src/sidecar/start.js +22 -7
  49. package/src/utils/abort-coordinator.js +57 -7
  50. package/src/utils/abort-result.js +36 -0
  51. package/src/utils/api-key-store.js +2 -13
  52. package/src/utils/cli-preflight.js +43 -0
  53. package/src/utils/config.js +30 -43
  54. package/src/utils/council-presets.js +87 -0
  55. package/src/utils/doctor-mcp-checks.js +84 -0
  56. package/src/utils/env-loader.js +1 -2
  57. package/src/utils/fold-marker.js +79 -0
  58. package/src/utils/idle-watchdog.js +9 -12
  59. package/src/utils/input-validators.js +52 -1
  60. package/src/utils/lifecycle.js +1 -1
  61. package/src/utils/mcp-discovery.js +80 -19
  62. package/src/utils/mcp-self-identity.js +12 -5
  63. package/src/utils/model-catalog.js +54 -6
  64. package/src/utils/read-slice.js +73 -0
  65. package/src/utils/remediation-hints.js +9 -0
  66. package/src/utils/result-schema-version.js +14 -0
  67. package/src/utils/result-schema.js +18 -12
  68. package/src/utils/session-abort.js +1 -1
  69. package/src/utils/session-index-tmp-sweep.js +80 -0
  70. package/src/utils/session-index.js +4 -5
  71. package/src/utils/session-path.js +6 -10
  72. package/src/utils/shared-server.js +7 -5
  73. package/src/utils/spend-ledger.js +80 -0
  74. package/src/utils/updater.js +2 -3
  75. 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 };
@@ -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 = getCompatEnv('CONFIG_DIR');
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
- const amicusDir = path.join(homeDir, '.config', 'amicus');
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 = getCouncil(name);
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 };
@@ -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 sidecar .env file
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
- * (legacy SIDECAR_IDLE_TIMEOUT_* still honored via env-compat shim)
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-compat suffixes */
26
+ /** @type {Object.<string, string>} Per-mode env var names */
30
27
  const MODE_ENV_MAP = {
31
- headless: 'IDLE_TIMEOUT_HEADLESS',
32
- interactive: 'IDLE_TIMEOUT_INTERACTIVE',
33
- server: 'IDLE_TIMEOUT_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 modeSuffix = MODE_ENV_MAP[mode];
45
- if (modeSuffix !== undefined) {
46
- const modeEnv = getCompatEnv(modeSuffix);
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 = getCompatEnv('IDLE_TIMEOUT');
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
- module.exports = { validateStartInputs, findSimilar };
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 };
@@ -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) {