amicus 1.0.0 → 1.2.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 (55) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +19 -0
  3. package/CHANGELOG.md +86 -0
  4. package/LICENSE +22 -1
  5. package/README.md +14 -3
  6. package/bin/amicus.js +17 -162
  7. package/electron/ipc-setup.js +30 -9
  8. package/electron/main.js +13 -5
  9. package/electron/preload.js +30 -10
  10. package/electron/setup-ui-keys.js +9 -0
  11. package/electron/setup-ui-model.js +33 -23
  12. package/electron/setup-ui-styles.js +6 -1
  13. package/electron/setup-ui.js +91 -38
  14. package/electron/toolbar.js +4 -5
  15. package/package.json +7 -5
  16. package/scripts/postinstall.js +16 -7
  17. package/skills/second-opinion/COUNCIL-DESIGN.md +36 -34
  18. package/skills/second-opinion/MODEL-NOTES.md +23 -17
  19. package/skills/second-opinion/SKILL.md +84 -51
  20. package/{skill → skills/sidecar}/SKILL.md +14 -4
  21. package/src/cli-handlers-council.js +59 -0
  22. package/src/cli-handlers-doctor.js +173 -0
  23. package/src/cli-handlers-run.js +196 -0
  24. package/src/cli-handlers.js +66 -1
  25. package/src/cli.js +16 -2
  26. package/src/council/findings.js +48 -0
  27. package/src/council/ledger.js +82 -0
  28. package/src/council/tally.js +108 -0
  29. package/src/council/verdict.js +48 -0
  30. package/src/headless.js +43 -149
  31. package/src/mcp-server.js +6 -0
  32. package/src/sidecar/budget.js +83 -0
  33. package/src/sidecar/conversation-mirror.js +128 -0
  34. package/src/sidecar/fanout-leg.js +4 -1
  35. package/src/sidecar/fanout.js +34 -7
  36. package/src/sidecar/interactive-mirror.js +66 -0
  37. package/src/sidecar/interactive.js +35 -21
  38. package/src/sidecar/models.js +41 -10
  39. package/src/sidecar/session-finalize.js +26 -0
  40. package/src/sidecar/session-utils.js +5 -5
  41. package/src/sidecar/setup.js +55 -42
  42. package/src/sidecar/start.js +19 -6
  43. package/src/utils/activity-poller.js +47 -0
  44. package/src/utils/alias-resolver.js +1 -1
  45. package/src/utils/config.js +4 -4
  46. package/src/utils/curated-models.js +88 -45
  47. package/src/utils/error-doc.js +55 -0
  48. package/src/utils/lifecycle.js +1 -1
  49. package/src/utils/model-catalog.js +1 -1
  50. package/src/utils/model-fetcher.js +16 -2
  51. package/src/utils/pricing.js +93 -0
  52. package/src/utils/quick-picks.js +81 -0
  53. package/src/utils/result-schema.js +21 -2
  54. package/src/utils/session-abort.js +40 -13
  55. package/src/utils/validators.js +17 -17
@@ -0,0 +1,93 @@
1
+ // src/utils/pricing.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module pricing
6
+ * Token aggregation + cached-pricing lookup + layered cost resolution (WS-2 #2).
7
+ * Cost is resolved in layers and ALWAYS tagged with its source so it can never
8
+ * be mistaken for an authoritative figure it isn't:
9
+ * reported — OpenCode billed cost (msg.info.cost > 0)
10
+ * estimated — tokens × cached catalog pricing
11
+ * unknown — neither available (e.g. a direct provider with pricing:null)
12
+ */
13
+
14
+ function emptyUsageTotals() {
15
+ return { tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, costReported: 0 };
16
+ }
17
+
18
+ /**
19
+ * Sum per-message usage. The poll loop re-reads ALL messages each poll, so the
20
+ * caller stores the latest snapshot per message id in a Map; summing the Map's
21
+ * values avoids double-counting streamed growth.
22
+ * @param {Map<string,{tokens?:object, cost?:number}>} map
23
+ */
24
+ function sumPerMessageUsage(map) {
25
+ const totals = emptyUsageTotals();
26
+ for (const v of map.values()) {
27
+ const t = v && v.tokens ? v.tokens : {};
28
+ totals.tokens.input += t.input || 0;
29
+ totals.tokens.output += t.output || 0;
30
+ totals.tokens.reasoning += t.reasoning || 0;
31
+ totals.tokens.cacheRead += (t.cache && t.cache.read) || 0;
32
+ totals.tokens.cacheWrite += (t.cache && t.cache.write) || 0;
33
+ if (typeof v.cost === 'number') { totals.costReported += v.cost; }
34
+ }
35
+ return totals;
36
+ }
37
+
38
+ /** Sync, non-refreshing cached-pricing lookup by full route id. @returns {{prompt,completion}|null} */
39
+ function lookupPricing(modelId) {
40
+ if (!modelId) { return null; }
41
+ let cache;
42
+ try { cache = require('./model-catalog').readCache(); } catch { return null; }
43
+ if (!cache || !Array.isArray(cache.models)) { return null; }
44
+ const row = cache.models.find(m => m && m.id === modelId);
45
+ if (!row || !row.pricing) { return null; }
46
+ const prompt = Number(row.pricing.prompt);
47
+ const completion = Number(row.pricing.completion);
48
+ if (!Number.isFinite(prompt) || !Number.isFinite(completion) || prompt < 0 || completion < 0) { return null; }
49
+ return { prompt, completion };
50
+ }
51
+
52
+ /** @returns {{amount:number|null, currency:'USD', source:'reported'|'estimated'|'unknown'}} */
53
+ function resolveLegCost({ reportedCost, tokens, pricing }) {
54
+ if (typeof reportedCost === 'number' && reportedCost > 0) {
55
+ return { amount: reportedCost, currency: 'USD', source: 'reported' };
56
+ }
57
+ if (pricing && tokens) {
58
+ const est = (tokens.input || 0) * pricing.prompt + (tokens.output || 0) * pricing.completion;
59
+ if (est > 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }
60
+ }
61
+ return { amount: null, currency: 'USD', source: 'unknown' };
62
+ }
63
+
64
+ /** Resolve a single run/leg's final usage block from raw totals + the model id. */
65
+ function resolveUsage({ model, usageTotals }) {
66
+ const totals = usageTotals || emptyUsageTotals();
67
+ const cost = resolveLegCost({ reportedCost: totals.costReported, tokens: totals.tokens, pricing: lookupPricing(model) });
68
+ return { tokens: totals.tokens, cost };
69
+ }
70
+
71
+ /** Aggregate leg usage into a wave-level usage block. Legs without usage count as unpriced. */
72
+ function sumWaveUsage(legs) {
73
+ const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
74
+ let amount = 0; let anyAmount = false;
75
+ let reportedLegs = 0, estimatedLegs = 0, unpricedLegs = 0;
76
+ for (const leg of legs) {
77
+ const u = leg && leg.usage;
78
+ if (!u || !u.cost) { unpricedLegs++; continue; }
79
+ for (const k of Object.keys(tokens)) { tokens[k] += (u.tokens && u.tokens[k]) || 0; }
80
+ if (typeof u.cost.amount === 'number') { amount += u.cost.amount; anyAmount = true; }
81
+ if (u.cost.source === 'reported') { reportedLegs++; }
82
+ else if (u.cost.source === 'estimated') { estimatedLegs++; }
83
+ else { unpricedLegs++; }
84
+ }
85
+ let source;
86
+ if (reportedLegs > 0 && estimatedLegs === 0 && unpricedLegs === 0) { source = 'reported'; }
87
+ else if (estimatedLegs > 0 && reportedLegs === 0 && unpricedLegs === 0) { source = 'estimated'; }
88
+ else if (reportedLegs === 0 && estimatedLegs === 0) { source = 'unknown'; }
89
+ else { source = 'mixed'; }
90
+ return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source, reportedLegs, estimatedLegs, unpricedLegs } };
91
+ }
92
+
93
+ module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Quick-pick resolution (wizard Step 2) — resolves each curated family to
3
+ * the current catalog flagship. Setup-time only; runtime alias resolution
4
+ * stays on the static DEFAULT_ALIASES (see curated-models.js).
5
+ *
6
+ * Ranking: numeric-descending over ids; a marker-suffixed variant
7
+ * (-preview/-exp/-beta/-latest/:free) loses ONLY to its own unmarked base,
8
+ * so gemini-3.1-pro-preview still beats the older stable gemini-2.5-pro.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { getFamilies, toDefaultAliases } = require('./curated-models');
14
+
15
+ const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
16
+
17
+ /** Numeric-desc comparator; same-base marker variant sorts after its base. */
18
+ function compareIdsDesc(a, b) {
19
+ const aBase = a.replace(MARKER_RE, '');
20
+ const bBase = b.replace(MARKER_RE, '');
21
+ if (aBase === bBase && a !== b) {
22
+ if (a === aBase) { return -1; }
23
+ if (b === bBase) { return 1; }
24
+ return a.localeCompare(b, 'en', { numeric: true });
25
+ }
26
+ return b.localeCompare(a, 'en', { numeric: true });
27
+ }
28
+
29
+ /**
30
+ * Newest catalog id under `<nsPrefix><vendorPath>/` whose model segment
31
+ * matches idPattern. nsPrefix is 'openrouter/' or '' (direct rows).
32
+ * @param {string} vendorPath - vendor segment in the OpenRouter namespace,
33
+ * or the provider name when matching direct-namespace rows (nsPrefix '').
34
+ * @returns {string|null} full catalog id
35
+ */
36
+ function pickCurrent(catalog, nsPrefix, vendorPath, idPattern) {
37
+ const prefix = `${nsPrefix}${vendorPath}/`;
38
+ const ids = (Array.isArray(catalog) ? catalog : [])
39
+ .map(m => m && m.id)
40
+ .filter(id => typeof id === 'string' && id.startsWith(prefix))
41
+ .filter(id => idPattern.test(id.slice(prefix.length)));
42
+ if (ids.length === 0) { return null; }
43
+ return ids.sort(compareIdsDesc)[0];
44
+ }
45
+
46
+ /**
47
+ * @param {Array<{id:string}>} catalog
48
+ * @returns {Array<{alias,label,blurb,source:'live'|'fallback',routes:Object<string,string>}>}
49
+ * `routes` may be empty if a family defines no fallback and the catalog has no match.
50
+ */
51
+ function resolveQuickPicks(catalog) {
52
+ return getFamilies().map(f => {
53
+ const routes = {};
54
+ let live = false;
55
+ const orPick = pickCurrent(catalog, 'openrouter/', f.vendorPath, f.idPattern);
56
+ if (orPick) { routes.openrouter = orPick; live = true; }
57
+ else if (f.fallback.openrouter) { routes.openrouter = f.fallback.openrouter; }
58
+ for (const p of f.directProviders) {
59
+ const direct = pickCurrent(catalog, '', p, f.idPattern);
60
+ if (direct) { routes[p] = direct; live = true; }
61
+ else if (f.fallback[p]) { routes[p] = f.fallback[p]; }
62
+ }
63
+ return { alias: f.alias, label: f.label, blurb: f.blurb, routes,
64
+ source: live ? 'live' : 'fallback' };
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Seed map for fresh configs: static defaults overlaid with live family
70
+ * openrouter routes (cardless aliases stay pinned).
71
+ * @returns {Object<string,string>}
72
+ */
73
+ function toLiveSeedAliases(catalog) {
74
+ const seeds = toDefaultAliases();
75
+ for (const r of resolveQuickPicks(catalog || [])) {
76
+ if (r.source === 'live' && r.routes.openrouter) { seeds[r.alias] = r.routes.openrouter; }
77
+ }
78
+ return seeds;
79
+ }
80
+
81
+ module.exports = { compareIdsDesc, pickCurrent, resolveQuickPicks, toLiveSeedAliases };
@@ -9,7 +9,7 @@
9
9
  * any rename/removal bumps SCHEMA_VERSION.
10
10
  */
11
11
 
12
- const SCHEMA_VERSION = 1;
12
+ const SCHEMA_VERSION = 2;
13
13
 
14
14
  /** Leg/run statuses that count as terminal for wave aggregation. */
15
15
  const TERMINAL_STATUSES = ['complete', 'error', 'timeout', 'aborted', 'crashed', 'idle-timeout'];
@@ -48,7 +48,7 @@ function durationBetween(createdAt, completedAt) {
48
48
  * @param {string|null} [opts.waveId] - Explicit wave id (falls back to metadata.parentWave)
49
49
  * @returns {object} run document
50
50
  */
51
- function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null }) {
51
+ function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
52
52
  const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
53
53
  const createdAt = metadata.createdAt || null;
54
54
  const completedAt = metadata.completedAt || metadata.abortedAt || null;
@@ -69,6 +69,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
69
69
  durationMs,
70
70
  sessionDir,
71
71
  opencodeSessionId: metadata.opencodeSessionId || null,
72
+ usage: usage !== null ? usage : (metadata.usage || null),
72
73
  };
73
74
  }
74
75
 
@@ -115,6 +116,7 @@ function waveExitCode(waveStatus) {
115
116
  * @returns {object} wave document
116
117
  */
117
118
  function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
119
+ const { sumWaveUsage } = require('./pricing');
118
120
  const counts = {
119
121
  total: legs.length,
120
122
  complete: legs.filter(l => l.status === 'complete').length,
@@ -135,6 +137,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
135
137
  createdAt,
136
138
  completedAt,
137
139
  durationMs,
140
+ usage: sumWaveUsage(legs),
138
141
  };
139
142
  }
140
143
 
@@ -246,6 +249,21 @@ function buildAuditDoc({ stale, catalogAvailable }) {
246
249
  };
247
250
  }
248
251
 
252
+ /**
253
+ * Build a doctor health-check document (`doctor --json`).
254
+ * @param {{version: string, timestamp: string, checks: Array<{id,name,status,message,hint}>}} opts
255
+ */
256
+ function buildDoctorDoc({ version, timestamp, checks }) {
257
+ return {
258
+ schemaVersion: SCHEMA_VERSION,
259
+ type: 'doctor',
260
+ ok: checks.every(c => c.status !== 'error'),
261
+ version,
262
+ timestamp,
263
+ checks,
264
+ };
265
+ }
266
+
249
267
  module.exports = {
250
268
  SCHEMA_VERSION,
251
269
  TERMINAL_STATUSES,
@@ -258,4 +276,5 @@ module.exports = {
258
276
  buildWaveResultFromSession,
259
277
  buildCatalogDoc,
260
278
  buildAuditDoc,
279
+ buildDoctorDoc,
261
280
  };
@@ -1,29 +1,33 @@
1
1
  /**
2
- * Session abort on process signals (F3 #20).
2
+ * Session abort utilities: signal handler installation and terminal metadata writes.
3
3
  *
4
- * When the parent process is killed (SIGTERM/SIGINT/SIGBREAK), the running
5
- * headless session must be aborted so no orphaned OpenCode session keeps
6
- * burning API credits. `markAborted` is the synchronous metadata write that
7
- * guarantees `amicus list` won't show an orphan afterward.
4
+ * Handles two related responsibilities: (1) installing process signal handlers
5
+ * (SIGINT, SIGTERM, SIGBREAK) that trigger abort callbacks, and (2) synchronously
6
+ * writing terminal status to session metadata (markTerminal and markAborted).
7
+ * These work together to ensure that when a session process is signaled, the
8
+ * session is marked as aborted immediately, preventing orphaned sessions from
9
+ * consuming API credits and ensuring `amicus list` reflects the true state.
8
10
  */
9
11
 
10
12
  const fs = require('fs');
11
13
  const path = require('path');
12
14
 
13
15
  /**
14
- * Synchronously mark a session's metadata as aborted. Best-effort: never throws.
16
+ * Synchronously write a terminal status to a session's metadata. Best-effort: never throws.
17
+ * `aborted` uses `abortedAt`; every other status uses `completedAt`.
15
18
  * @param {string} sessionDir
16
- * @param {string} reason - e.g. a signal name
17
- * @returns {boolean} true if the session was marked aborted
19
+ * @param {'aborted'|'timed-out'|'error'|'complete'} status
20
+ * @param {string} reason
21
+ * @returns {boolean} true if written
18
22
  */
19
- function markAborted(sessionDir, reason) {
23
+ function markTerminal(sessionDir, status, reason) {
20
24
  try {
21
25
  const metaPath = path.join(sessionDir, 'metadata.json');
22
26
  if (!fs.existsSync(metaPath)) { return false; }
23
27
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
24
- meta.status = 'aborted';
25
- meta.reason = `Aborted (${reason})`;
26
- meta.abortedAt = new Date().toISOString();
28
+ meta.status = status;
29
+ meta.reason = reason;
30
+ meta[status === 'aborted' ? 'abortedAt' : 'completedAt'] = new Date().toISOString();
27
31
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
28
32
  return true;
29
33
  } catch {
@@ -31,6 +35,11 @@ function markAborted(sessionDir, reason) {
31
35
  }
32
36
  }
33
37
 
38
+ /** Mark a session aborted (preserves prior behavior). */
39
+ function markAborted(sessionDir, reason) {
40
+ return markTerminal(sessionDir, 'aborted', `Aborted (${reason})`);
41
+ }
42
+
34
43
  /**
35
44
  * Register signal handlers that call onAbort(signal). Returns an uninstall fn.
36
45
  * @param {{onAbort: (signal: string) => void, signals?: string[]}} opts
@@ -50,4 +59,22 @@ function installSignalAbort({ onAbort, signals = ['SIGINT', 'SIGTERM', 'SIGBREAK
50
59
  };
51
60
  }
52
61
 
53
- module.exports = { markAborted, installSignalAbort };
62
+ /**
63
+ * Idle-backstop teardown: mark timed-out, write a stub summary, close the owned server.
64
+ * Returns the exit code (always 2).
65
+ * @param {string} sessionDir
66
+ * @param {{close: () => void}|null} server
67
+ * @param {boolean} externalServer - when true, the server is not ours to close
68
+ * @returns {2}
69
+ */
70
+ function idleBackstopTeardown(sessionDir, server, externalServer) {
71
+ try {
72
+ markTerminal(sessionDir, 'timed-out', 'Idle backstop timeout');
73
+ fs.writeFileSync(path.join(sessionDir, 'summary.md'),
74
+ 'Session timed out — idle backstop fired before completion.\n', { mode: 0o600 });
75
+ } catch { /* best-effort */ }
76
+ if (!externalServer && server) { try { server.close(); } catch { /* best-effort */ } }
77
+ return 2;
78
+ }
79
+
80
+ module.exports = { markTerminal, markAborted, installSignalAbort, idleBackstopTeardown };
@@ -168,7 +168,7 @@ function validateExplicitSession(session, _projectPath) {
168
168
  if (!found) {
169
169
  return {
170
170
  valid: false,
171
- error: `Error: --session '${session}' not found. Use 'sidecar list' to see available sessions or omit --session for most recent.`
171
+ error: `Error: --session '${session}' not found. Use 'amicus list' to see available sessions or omit --session for most recent.`
172
172
  };
173
173
  }
174
174
 
@@ -176,12 +176,7 @@ function validateExplicitSession(session, _projectPath) {
176
176
  }
177
177
 
178
178
  /**
179
- * Validate agent mode
180
- *
181
- * Accepts:
182
- * - OpenCode native agents: Build, Plan, General, Explore
183
- * - Custom agents: any non-empty string (for user-defined OpenCode agents)
184
- *
179
+ * Validate agent mode (native OpenCode agents or any non-empty custom agent string).
185
180
  * @param {string} agent
186
181
  * @returns {{valid: boolean, error?: string}}
187
182
  */
@@ -239,11 +234,8 @@ const { MODEL_THINKING_SUPPORT, getSupportedThinkingLevels, validateThinkingLeve
239
234
 
240
235
  /**
241
236
  * Validate API key is present for the given model's provider.
242
- *
243
- * Assumes loadCredentials() has already run, projecting all credential
244
- * sources (sidecar .env, auth.json) into process.env.
245
- *
246
- * @param {string} model - The model string (e.g., 'openrouter/google/gemini-2.5-flash')
237
+ * Assumes loadCredentials() has already run (projects .env + auth.json → process.env).
238
+ * @param {string} model - e.g. 'openrouter/google/gemini-2.5-flash'
247
239
  * @returns {{valid: boolean, error?: string}}
248
240
  */
249
241
  function validateApiKey(model) {
@@ -259,14 +251,22 @@ function validateApiKey(model) {
259
251
  }
260
252
 
261
253
  if (!process.env[providerInfo.key]) {
254
+ const keyName = providerInfo.key;
255
+ const isWin = process.platform === 'win32';
256
+ const persist = isWin
257
+ ? ` - Persist it for new shells: setx ${keyName} <your-key>\n` +
258
+ ` (or add $env:${keyName} to your PowerShell $PROFILE)\n`
259
+ : ` - Persist it across shells: add 'export ${keyName}=<your-key>' to ~/.zshenv\n` +
260
+ ' (non-interactive shells like Claude Code and CI do not source ~/.zshrc)\n';
262
261
  return {
263
262
  valid: false,
264
- error: `Error: ${providerInfo.key} not found.\n\n` +
265
- 'In non-interactive shells (Claude Code, CI), ~/.zshrc is not sourced.\n' +
263
+ code: 'MISSING_KEY',
264
+ error:
265
+ `Error: ${keyName} not found for ${providerInfo.name}.\n\n` +
266
266
  'Fix with one of:\n' +
267
- ' - Run `sidecar setup` to store keys in sidecar\'s config\n' +
268
- ' - Move your export to ~/.zshenv (sourced by all zsh shells)\n' +
269
- ' - Add key to ~/.local/share/opencode/auth.json'
267
+ ` - Store it in Amicus (recommended): amicus key ${provider} <apikey>\n` +
268
+ persist +
269
+ ' - Or add it to ~/.local/share/opencode/auth.json\n',
270
270
  };
271
271
  }
272
272