amicus 1.2.1 → 1.4.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/src/mcp-server.js CHANGED
@@ -8,7 +8,7 @@ const os = require('os');
8
8
  const { logger } = require('./utils/logger');
9
9
  const { safeSessionDir } = require('./utils/validators');
10
10
  const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
11
- const { readProgress } = require('./sidecar/progress');
11
+ const { readProgress, isStalled } = require('./sidecar/progress');
12
12
  const { SharedServerManager } = require('./utils/shared-server');
13
13
 
14
14
  const sharedServer = new SharedServerManager({ logger });
@@ -264,7 +264,14 @@ const handlers = {
264
264
  if (metadata.type === 'wave') {
265
265
  const legs = (metadata.legs || []).map((legId) => {
266
266
  const m = readMetadata(legId, cwd);
267
- return { taskId: legId, model: (m && m.model) || null, status: (m && m.status) || 'unknown' };
267
+ const leg = { taskId: legId, model: (m && m.model) || null, status: (m && m.status) || 'unknown' };
268
+ try {
269
+ const p = readProgress(getSessionDir(cwd, legId));
270
+ leg.messages = p.messages;
271
+ leg.latestActivity = p.latest;
272
+ leg.stalled = leg.status === 'running' && isStalled(p.lastActivityMs);
273
+ } catch { /* no progress yet — leave base fields only */ }
274
+ return leg;
268
275
  });
269
276
  const { TERMINAL_STATUSES } = require('./utils/result-schema');
270
277
  const done = legs.filter(l => TERMINAL_STATUSES.includes(l.status)).length;
@@ -524,9 +531,38 @@ const handlers = {
524
531
  async amicus_fanout(input, project) {
525
532
  const cwd = project || getProjectDir(input.project);
526
533
  const { generateTaskId } = require('./sidecar/start');
527
- const { deriveLegIds } = require('./sidecar/fanout');
534
+ const { deriveLegIds, DEFAULT_MAX_LEGS } = require('./sidecar/fanout');
535
+
536
+ // Resolve a single effective models list (council OR models), validated
537
+ // BEFORE any wave dir / metadata is written so a bad request never strands
538
+ // a pid-less 'running' orphan wave.
539
+ const inputModels = Array.isArray(input.models) ? input.models : [];
540
+ const hasModels = inputModels.length > 0;
541
+ const hasCouncil = typeof input.council === 'string' && input.council.trim();
542
+ if (hasModels && hasCouncil) {
543
+ return textResult("Pass exactly one of 'models' / 'council', not both.", true);
544
+ }
545
+ let effectiveModels;
546
+ if (hasCouncil) {
547
+ const { resolveCouncilMembers } = require('./utils/config');
548
+ const { readCache } = require('./utils/model-catalog');
549
+ const catalog = (readCache() || {}).models || [];
550
+ const expanded = resolveCouncilMembers(input.council.trim(), catalog);
551
+ if (expanded.error) { return textResult(expanded.error, true); }
552
+ effectiveModels = expanded.models;
553
+ } else if (hasModels) {
554
+ effectiveModels = inputModels;
555
+ } else {
556
+ return textResult("Provide 'models' or 'council'.", true);
557
+ }
558
+ const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
559
+ const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
560
+ if (effectiveModels.length > maxLegs) {
561
+ return textResult(`Council/model list exceeds the fan-out cap of ${maxLegs} legs.`, true);
562
+ }
563
+
528
564
  const waveId = generateTaskId();
529
- const legIds = deriveLegIds(waveId, input.models.length);
565
+ const legIds = deriveLegIds(waveId, effectiveModels.length);
530
566
  const waveDir = getSessionDir(cwd, waveId);
531
567
 
532
568
  let briefingPath;
@@ -538,14 +574,14 @@ const handlers = {
538
574
  fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
539
575
  fs.writeFileSync(path.join(waveDir, 'metadata.json'), JSON.stringify({
540
576
  taskId: waveId, type: 'wave', status: 'running', legs: legIds,
541
- models: input.models, headless: true, createdAt: new Date().toISOString(),
577
+ models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
542
578
  }, null, 2), { mode: 0o600 });
543
579
  } catch (err) {
544
580
  return textResult(`Failed to prepare fan-out wave: ${err.message}`, true);
545
581
  }
546
582
 
547
583
  const args = [
548
- 'fanout', '--models', input.models.join(','),
584
+ 'fanout', '--models', effectiveModels.join(','),
549
585
  '--prompt-file', briefingPath, '--wave-id', waveId,
550
586
  '--json', '--client', 'cowork', '--cwd', cwd,
551
587
  ];
@@ -574,6 +610,27 @@ const handlers = {
574
610
  return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
575
611
  },
576
612
 
613
+ async amicus_council_tally(input) {
614
+ try {
615
+ const { tally } = require('./council/tally');
616
+ return textResult(JSON.stringify(tally(input)));
617
+ } catch (err) { return textResult(`council tally failed: ${err.message}`, true); }
618
+ },
619
+
620
+ async amicus_council_stats() {
621
+ try {
622
+ const { deriveReliability } = require('./council/ledger');
623
+ return textResult(JSON.stringify(deriveReliability()));
624
+ } catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
625
+ },
626
+
627
+ async amicus_verdict(input) {
628
+ try {
629
+ const { buildVerdict } = require('./council/verdict');
630
+ return textResult(JSON.stringify(buildVerdict(input.record, input.decisions || [])));
631
+ } catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
632
+ },
633
+
577
634
  async amicus_setup() {
578
635
  try { spawnSidecarProcess(['setup']); } catch (err) {
579
636
  return textResult(`Failed to launch setup: ${err.message}`, true);
@@ -592,6 +649,9 @@ const LEGACY_TOOL_ALIASES = {
592
649
  amicus_setup: 'sidecar_setup', amicus_abort: 'sidecar_abort',
593
650
  amicus_fanout: 'sidecar_fanout',
594
651
  amicus_guide: 'sidecar_guide',
652
+ amicus_council_tally: 'sidecar_council_tally',
653
+ amicus_council_stats: 'sidecar_council_stats',
654
+ amicus_verdict: 'sidecar_verdict',
595
655
  };
596
656
 
597
657
  /** Start the MCP server on stdio transport */
package/src/mcp-tools.js CHANGED
@@ -255,8 +255,11 @@ function getTools() {
255
255
  'document (per-leg summaries inside). Each leg is also an ordinary ' +
256
256
  'session readable by taskId.',
257
257
  inputSchema: {
258
- models: z.array(safeModel).min(1).max(10).describe(
259
- `1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed.`
258
+ models: z.array(safeModel).min(1).max(10).optional().describe(
259
+ `1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed. Omit when using 'council'.`
260
+ ),
261
+ council: z.string().optional().describe(
262
+ "Run a saved council by name (e.g. 'free') instead of 'models'. Expands to the council's members. Mutually exclusive with 'models'."
260
263
  ),
261
264
  prompt: z.string().describe(
262
265
  'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
@@ -281,6 +284,62 @@ function getTools() {
281
284
  ),
282
285
  },
283
286
  },
287
+ {
288
+ name: 'amicus_council_tally',
289
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
290
+ description:
291
+ 'Deterministic council tally over an ASSEMBLED, de-anonymized input ' +
292
+ '(meta + findings + adjudications + rankings). Peers-only tier cascade ' +
293
+ '(Confirmed/Contested/Disputed/Singleton) + street-cred. Pure + synchronous: ' +
294
+ 'returns the tally record immediately. No subprocess, no polling. Claude ' +
295
+ 'assembles the input and may override margin tiers afterward.',
296
+ inputSchema: {
297
+ meta: z.object({
298
+ runId: z.string(), runType: z.string().optional(), date: z.string().optional(),
299
+ models: z.array(z.string()).min(1), chair: z.string().optional(),
300
+ claudeInCouncil: z.boolean().optional(),
301
+ }).describe('Run metadata; meta.models lists every reviewed model.'),
302
+ findings: z.array(z.object({
303
+ id: z.string(), raiser: z.string(), severity: z.string(), claim: z.string().optional(),
304
+ })).describe('Run-global findings (ids already A1/B2/C3-prefixed by Claude).'),
305
+ adjudications: z.array(z.object({
306
+ judge: z.string(), findingId: z.string(), verdict: z.enum(['agree', 'dispute', 'neutral']),
307
+ })).describe('One row per (judge × finding).'),
308
+ rankings: z.array(z.object({
309
+ judge: z.string(), order: z.array(z.union([z.string(), z.array(z.string())])),
310
+ })).describe("Each judge's preference order over the reviews (ties = nested array)."),
311
+ runStats: z.array(z.record(z.any())).optional().describe('Optional per-model run stats (status/duration/usage).'),
312
+ project: z.string().optional().describe('Optional project directory path.'),
313
+ },
314
+ },
315
+ {
316
+ name: 'amicus_council_stats',
317
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
318
+ description:
319
+ 'Per-model reviewer reliability derived from the append-only council ledger ' +
320
+ '(avg peers-only street-cred, lifetime confirm/fact-error rates). Read-only; ' +
321
+ 'no inputs required.',
322
+ inputSchema: {
323
+ project: z.string().optional().describe('Optional project directory path.'),
324
+ },
325
+ },
326
+ {
327
+ name: 'amicus_verdict',
328
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
329
+ description:
330
+ "Merge a tally record with Claude's Stage-4 decisions into the verdict " +
331
+ 'object (final tiers after overrides, decisions, applied flags). Pure + ' +
332
+ 'synchronous; returns the verdict — does NOT write it to disk.',
333
+ inputSchema: {
334
+ record: z.record(z.any()).describe('A tally() output record (from amicus_council_tally).'),
335
+ decisions: z.array(z.object({
336
+ id: z.string(), decision: z.string().optional(), applied: z.boolean().optional(),
337
+ duplicateOf: z.string().nullable().optional(),
338
+ tierOverride: z.object({ from: z.string(), to: z.string(), reason: z.string() }).nullable().optional(),
339
+ })).optional().describe('Stage-4 per-finding decisions (default []).'),
340
+ project: z.string().optional().describe('Optional project directory path.'),
341
+ },
342
+ },
284
343
  {
285
344
  name: 'amicus_guide',
286
345
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -1,5 +1,6 @@
1
1
  // src/sidecar/fanout-output.js
2
2
  'use strict';
3
+ const { formatCost } = require('../utils/pricing');
3
4
 
4
5
  /**
5
6
  * @module fanout-output
@@ -36,9 +37,11 @@ function formatWaveHuman(wave) {
36
37
  lines.push('─'.repeat(40));
37
38
  const counts = wave.counts || { complete: '?', total: '?' };
38
39
  lines.push(`Wave ${wave.waveId}: ${wave.status} — ${counts.complete}/${counts.total} complete in ${fmtDuration(wave.durationMs)}`);
40
+ lines.push(` Wave cost: ${formatCost(wave.usage && wave.usage.cost)}`);
39
41
  for (const leg of wave.legs) {
40
42
  const label = leg.modelInput || leg.model || leg.taskId;
41
- lines.push(` ${leg.taskId} ${String(label).padEnd(12)} ${String(leg.status).padEnd(9)} ${fmtDuration(leg.durationMs)}`);
43
+ lines.push(` ${leg.taskId} ${String(label).padEnd(12)} ${String(leg.status).padEnd(9)} ` +
44
+ `${String(fmtDuration(leg.durationMs)).padEnd(7)} ${formatCost(leg.usage && leg.usage.cost)}`);
42
45
  }
43
46
  return lines.join('\n');
44
47
  }
@@ -109,7 +109,8 @@ function writeWaveMetadata(waveDir, patch) {
109
109
  async function runFanout(options) {
110
110
  const { buildWaveResult, waveExitCode } = require('../utils/result-schema');
111
111
  const { generateTaskId, buildMcpConfig } = require('./start');
112
- const { startOpenCodeServer, createHeartbeat, HEARTBEAT_INTERVAL } = require('./session-utils');
112
+ const { startOpenCodeServer, HEARTBEAT_INTERVAL } = require('./session-utils');
113
+ const { createWaveHeartbeat } = require('./wave-progress');
113
114
  const { buildContext } = require('./context-builder');
114
115
  const { buildPrompts } = require('../prompt-builder');
115
116
  const { installSignalAbort, markAborted } = require('../utils/session-abort');
@@ -223,7 +224,12 @@ async function runFanout(options) {
223
224
  });
224
225
 
225
226
  // 6. Launch all legs concurrently (runLeg never rejects)
226
- const heartbeat = options.quiet ? { stop() {} } : createHeartbeat(HEARTBEAT_INTERVAL);
227
+ const heartbeat = options.quiet
228
+ ? { stop() {} }
229
+ : createWaveHeartbeat(
230
+ legs.map((leg, i) => ({ label: leg.modelInput || leg.model, dir: legDirs[i] })),
231
+ HEARTBEAT_INTERVAL
232
+ );
227
233
  const timeoutMs = (options.timeout || 15) * 60 * 1000;
228
234
  const reasoning = options.thinking ? { effort: options.thinking } : undefined;
229
235
  let legDocs;
@@ -87,6 +87,19 @@ function computeLastActivity(mtime) {
87
87
  return `${diffHr}h ago`;
88
88
  }
89
89
 
90
+ /** A leg with no new activity for longer than this (ms) is flagged stalled in rollups. */
91
+ const STALL_MS = 60000;
92
+
93
+ /**
94
+ * Is a leg stalled? True only when we have a real idle measurement that exceeds
95
+ * the threshold; an unknown / just-started leg (null) is never "stalled".
96
+ * @param {number|null|undefined} lastActivityMs ms since last activity
97
+ * @returns {boolean}
98
+ */
99
+ function isStalled(lastActivityMs) {
100
+ return typeof lastActivityMs === 'number' && lastActivityMs > STALL_MS;
101
+ }
102
+
90
103
  /**
91
104
  * Write a progress update to progress.json.
92
105
  *
@@ -214,5 +227,7 @@ module.exports = {
214
227
  writeProgress,
215
228
  extractLatest,
216
229
  computeLastActivity,
217
- STAGE_LABELS
230
+ STAGE_LABELS,
231
+ STALL_MS,
232
+ isStalled
218
233
  };
@@ -40,19 +40,23 @@ function addAlias(name, modelString) {
40
40
  }
41
41
 
42
42
  /**
43
- * Create a new config with all default aliases and the chosen default model
43
+ * Ensure a config exists with the chosen default model. Read-modify-write:
44
+ * preserves every pre-existing top-level key (aliases, councils, …) and only
45
+ * fills in the default + any missing default aliases. Never clobbers.
44
46
  * @param {string} defaultModel - Default model alias or full model string
45
- * @returns {object} The created config object
47
+ * @returns {object} The resulting config object
46
48
  */
47
49
  function createDefaultConfig(defaultModel) {
50
+ const existing = loadConfig() || {};
48
51
  const cfg = {
49
- default: defaultModel,
50
- aliases: getDefaultAliases()
52
+ ...existing,
53
+ default: existing.default || defaultModel,
54
+ aliases: { ...getDefaultAliases(), ...(existing.aliases || {}) },
51
55
  };
52
56
  saveConfig(cfg);
53
- logger.info('Default config created', {
54
- default: defaultModel,
55
- aliasCount: Object.keys(cfg.aliases).length
57
+ logger.info('Default config ensured', {
58
+ default: cfg.default,
59
+ aliasCount: Object.keys(cfg.aliases).length,
56
60
  });
57
61
  return cfg;
58
62
  }
@@ -152,13 +156,70 @@ async function seedCatalog(print) {
152
156
  log('Model catalog unavailable (offline?) — it will refresh on first start.');
153
157
  }
154
158
 
159
+ /**
160
+ * Free OpenRouter council branch of the readline wizard. Requires
161
+ * OPENROUTER_API_KEY; lists free catalog models, lets the user multi-pick
162
+ * (Enter = the vendor-diverse default), seeds aliases + councils.free, and
163
+ * never touches config.default.
164
+ * @param {readline.Interface} rl
165
+ */
166
+ async function runFreeCouncilBranch(rl) {
167
+ const keys = detectApiKeys();
168
+ if (!keys.openrouter) {
169
+ console.log('');
170
+ console.log('A free council needs OPENROUTER_API_KEY (free models route only through OpenRouter).');
171
+ console.log('Set OPENROUTER_API_KEY and re-run: amicus setup. No changes made.');
172
+ return;
173
+ }
174
+ const { getCatalog } = require('../utils/model-catalog');
175
+ const { listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS } = require('../utils/free-models');
176
+ let catalog = [];
177
+ try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
178
+ let free = listFreeModels(catalog);
179
+ if (free.length === 0) {
180
+ console.log('Live free-model list unavailable (offline?) — using a small pinned set.');
181
+ free = PINNED_FREE_MODELS.map(id => ({ id }));
182
+ }
183
+ const defaults = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
184
+ console.log('');
185
+ console.log('Free OpenRouter models (★ = default council):');
186
+ free.forEach((r, i) => {
187
+ const star = defaults.has(r.id) ? '★' : ' ';
188
+ console.log(` ${star} ${i + 1}) ${r.id}`);
189
+ });
190
+ console.log('');
191
+ const answer = await askQuestion(rl,
192
+ 'Pick members (comma-separated numbers, or Enter for the ★ default): ');
193
+ let pickIds;
194
+ if (!answer) {
195
+ pickIds = free.filter(r => defaults.has(r.id)).map(r => r.id);
196
+ } else {
197
+ pickIds = answer.split(',').map(s => parseInt(s.trim(), 10))
198
+ .filter(n => n >= 1 && n <= free.length).map(n => free[n - 1].id);
199
+ }
200
+ if (pickIds.length < 2) {
201
+ console.log('A council needs at least 2 models. No changes made.');
202
+ return;
203
+ }
204
+ const { council } = seedFreeCouncil(pickIds);
205
+ await seedCatalog();
206
+ console.log('');
207
+ console.log(`Free council saved: councils.free = [${council.join(', ')}]`);
208
+ console.log('Run it: amicus fanout --council free --prompt "..."');
209
+ console.log('config.default left unchanged.');
210
+ console.log('');
211
+ console.log('Heads up (free tier): rate-limited & quality-variable; some models 404');
212
+ console.log('unless you enable data-sharing at openrouter.ai/settings/privacy.');
213
+ }
214
+
155
215
  /**
156
216
  * Run the readline-based setup wizard (headless fallback)
157
217
  *
158
218
  * Guides the user through:
159
219
  * 1. API key detection
160
- * 2. Default model selection from live quick-picks (read-modify-write, no clobber)
161
- * 3. Config file save
220
+ * 2. Mode selection (standard or free council)
221
+ * 3. Default model selection from live quick-picks (read-modify-write, no clobber)
222
+ * 4. Config file save
162
223
  */
163
224
  async function runReadlineSetup() {
164
225
  const rl = readline.createInterface({
@@ -185,6 +246,13 @@ async function runReadlineSetup() {
185
246
  }
186
247
  console.log('');
187
248
 
249
+ const mode = await askQuestion(rl,
250
+ 'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
251
+ if (mode === '2') {
252
+ await runFreeCouncilBranch(rl);
253
+ return;
254
+ }
255
+
188
256
  const { getCatalog } = require('../utils/model-catalog');
189
257
  const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
190
258
  let catalog = [];
@@ -282,12 +350,64 @@ async function runInteractiveSetup() {
282
350
 
283
351
  /* eslint-enable no-console */
284
352
 
353
+ /**
354
+ * Collision-safe alias name from a free model id. Strips the openrouter/
355
+ * prefix and trailing :free, sanitizes '/'/':' to '-', prefixes 'free-',
356
+ * and disambiguates against `taken` with a numeric suffix.
357
+ * @param {string} id e.g. openrouter/deepseek/deepseek-r1:free
358
+ * @param {Set<string>} taken alias names already in use
359
+ * @returns {string} e.g. free-deepseek-deepseek-r1
360
+ */
361
+ function deriveFreeAlias(id, taken) {
362
+ const base = 'free-' + id
363
+ .replace(/^openrouter\//, '')
364
+ .replace(/:free$/, '')
365
+ .replace(/[/:]/g, '-')
366
+ .replace(/-+/g, '-');
367
+ let name = base;
368
+ let n = 2;
369
+ while (taken.has(name)) { name = `${base}-${n++}`; }
370
+ taken.add(name);
371
+ return name;
372
+ }
373
+
374
+ /**
375
+ * Seed free-model aliases + councils.free from chosen catalog ids.
376
+ * Single atomic read-modify-write. Reuses an existing alias that already
377
+ * maps to the same id (idempotent re-runs); never touches config.default.
378
+ * @param {string[]} pickIds full openrouter/.../...:free ids
379
+ * @returns {{added: Array<{alias:string, model:string}>, council: string[]}}
380
+ */
381
+ function seedFreeCouncil(pickIds) {
382
+ const cfg = loadConfig() || { aliases: {} };
383
+ if (!cfg.aliases) { cfg.aliases = {}; }
384
+ const taken = new Set(Object.keys(cfg.aliases));
385
+ const council = [];
386
+ const added = [];
387
+ for (const id of pickIds) {
388
+ const existing = Object.entries(cfg.aliases).find(([, m]) => m === id);
389
+ if (existing) { if (!council.includes(existing[0])) { council.push(existing[0]); } continue; }
390
+ const alias = deriveFreeAlias(id, taken);
391
+ cfg.aliases[alias] = id;
392
+ added.push({ alias, model: id });
393
+ council.push(alias);
394
+ }
395
+ if (!cfg.councils) { cfg.councils = {}; }
396
+ cfg.councils.free = Array.from(new Set(council));
397
+ saveConfig(cfg);
398
+ logger.info('Free council seeded', { count: cfg.councils.free.length });
399
+ return { added, council: cfg.councils.free };
400
+ }
401
+
285
402
  module.exports = {
286
403
  addAlias,
287
404
  createDefaultConfig,
405
+ deriveFreeAlias,
288
406
  detectApiKeys,
407
+ runFreeCouncilBranch,
289
408
  runInteractiveSetup,
290
409
  runReadlineSetup,
291
410
  runApiKeySetup,
292
411
  seedCatalog,
412
+ seedFreeCouncil,
293
413
  };
@@ -0,0 +1,80 @@
1
+ // src/sidecar/wave-progress.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module wave-progress
6
+ * Per-leg live progress rollup for a fan-out wave. Each headless leg already
7
+ * writes progress.json + conversation.jsonl to its own session dir; this reads
8
+ * them on a timer and prints ONE terse line per leg to stderr — milestones,
9
+ * never a token firehose (all three council models flagged firehose noise).
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { readProgress, isStalled } = require('./progress');
15
+
16
+ const WAVE_HEARTBEAT_INTERVAL = 15000;
17
+
18
+ /**
19
+ * Render one compact status line per leg. Pure: takes already-read leg states.
20
+ * @param {Array<{label:string, messages:number, latest:string, stage?:string, stalled:boolean}>} legStates
21
+ * @returns {string}
22
+ */
23
+ function formatWaveProgress(legStates) {
24
+ return legStates.map((s) => {
25
+ const stage = s.stage || 'starting';
26
+ const flag = s.stalled ? ' ⏳stalled' : '';
27
+ return `[amicus] ${String(s.label).padEnd(16)} ${String(stage).padEnd(10)} ` +
28
+ `${s.messages} msg | ${s.latest}${flag}`;
29
+ }).join('\n');
30
+ }
31
+
32
+ /**
33
+ * Read a single leg's live state from its session dir. Degrades gracefully when
34
+ * progress.json is absent (a leg that has not started writing yet).
35
+ * @param {{label:string, dir:string}} leg
36
+ * @returns {{label:string, messages:number, latest:string, stage?:string, stalled:boolean}}
37
+ */
38
+ function readLegState(leg) {
39
+ const progressPath = path.join(leg.dir, 'progress.json');
40
+ const convPath = path.join(leg.dir, 'conversation.jsonl');
41
+
42
+ // Degrade gracefully when neither file has been written yet
43
+ if (!fs.existsSync(progressPath) && !fs.existsSync(convPath)) {
44
+ return { label: leg.label, messages: 0, latest: 'starting…', stalled: false };
45
+ }
46
+
47
+ let p;
48
+ try { p = readProgress(leg.dir); } catch { p = null; }
49
+ if (!p) { return { label: leg.label, messages: 0, latest: 'starting…', stalled: false }; }
50
+
51
+ const state = {
52
+ label: leg.label,
53
+ messages: p.messages,
54
+ latest: p.latest,
55
+ stalled: isStalled(p.lastActivityMs),
56
+ };
57
+ if (p.stage !== undefined) {
58
+ state.stage = p.stage;
59
+ }
60
+ return state;
61
+ }
62
+
63
+ /**
64
+ * Start a wave heartbeat that prints a per-leg rollup each tick. Mirrors the
65
+ * createHeartbeat contract: returns { stop() }.
66
+ * @param {Array<{label:string, dir:string}>} legs
67
+ * @param {number} [interval]
68
+ * @returns {{stop: () => void}}
69
+ */
70
+ function createWaveHeartbeat(legs, interval = WAVE_HEARTBEAT_INTERVAL) {
71
+ const startTime = Date.now();
72
+ const intervalId = setInterval(() => {
73
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
74
+ const states = legs.map(readLegState);
75
+ process.stderr.write(`[amicus] wave ${elapsed}s — ${states.length} legs\n${formatWaveProgress(states)}\n`);
76
+ }, interval);
77
+ return { stop() { clearInterval(intervalId); } };
78
+ }
79
+
80
+ module.exports = { formatWaveProgress, readLegState, createWaveHeartbeat, WAVE_HEARTBEAT_INTERVAL };
@@ -273,6 +273,57 @@ function detectFallback(alias, resolvedModel) {
273
273
  return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
274
274
  }
275
275
 
276
+ /** @returns {Object<string,string[]>} the councils map (empty if none) */
277
+ function getCouncils() {
278
+ const config = loadConfig();
279
+ return (config && config.councils) || {};
280
+ }
281
+
282
+ /** @param {string} name @returns {string[]|null} council members, or null if absent */
283
+ function getCouncil(name) {
284
+ return getCouncils()[name] || null;
285
+ }
286
+
287
+ /**
288
+ * Expand a saved council into a runnable members list, degrading gracefully.
289
+ * Each member is resolved to its full model id (alias → id via effective
290
+ * aliases; a member containing '/' is taken as-is) and that id checked against
291
+ * the cached catalog. Unresolvable aliases and delisted ids are dropped with a
292
+ * warning rather than fail-fast-aborting the whole wave. The catalog check is
293
+ * skipped when the catalog is empty (offline). Returns members RAW (alias or
294
+ * id) — leg-time validation resolves them again.
295
+ * @param {string} name
296
+ * @param {Array<{id:string}>} [catalog]
297
+ * @returns {{models:string[], dropped:string[]} | {error:string}}
298
+ */
299
+ function resolveCouncilMembers(name, catalog = []) {
300
+ const members = getCouncil(name);
301
+ if (!members) {
302
+ return { error: `Unknown council '${name}'. Run 'amicus setup' to create one.` };
303
+ }
304
+ if (!Array.isArray(members) || members.length === 0) {
305
+ return { error: `Council '${name}' is empty. Run 'amicus setup' to populate it.` };
306
+ }
307
+ const aliases = getEffectiveAliases();
308
+ const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
309
+ const models = [];
310
+ const dropped = [];
311
+ for (const member of members) {
312
+ const id = member.includes('/') ? member : aliases[member];
313
+ if (!id) { dropped.push(member); continue; } // alias no longer resolves
314
+ if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
315
+ models.push(member);
316
+ }
317
+ if (models.length < 2) {
318
+ return {
319
+ error: `Council '${name}' has fewer than 2 usable members` +
320
+ (dropped.length ? ` (dropped: ${dropped.join(', ')})` : '') +
321
+ '. Run \'amicus setup\' to refresh it.',
322
+ };
323
+ }
324
+ return { models, dropped };
325
+ }
326
+
276
327
  module.exports = {
277
328
  getConfigDir,
278
329
  getConfigPath,
@@ -288,4 +339,7 @@ module.exports = {
288
339
  formatAliasNames,
289
340
  tryResolveModel,
290
341
  buildProviderModels,
342
+ getCouncils,
343
+ getCouncil,
344
+ resolveCouncilMembers,
291
345
  };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Free OpenRouter model detection (Unit A).
3
+ *
4
+ * A free model is an openrouter/* catalog id whose slug ends in ':free'.
5
+ * The ':free' suffix is OpenRouter's authoritative free-tier marker. A
6
+ * zero prompt/completion price is deliberately NOT used: the catalog
7
+ * normalizer keeps only {prompt, completion} and discards request/image
8
+ * pricing, so a per-request-charged model with prompt:'0' would be
9
+ * mislabeled. Pure + network-free.
10
+ */
11
+ 'use strict';
12
+
13
+ /** Offline last-resort free ids (used only when the live catalog is empty). */
14
+ const PINNED_FREE_MODELS = [
15
+ 'openrouter/deepseek/deepseek-r1:free',
16
+ 'openrouter/google/gemini-2.0-flash-exp:free',
17
+ 'openrouter/qwen/qwen3-coder:free',
18
+ ];
19
+
20
+ /** @param {{id?:string}} row @returns {boolean} */
21
+ function isFreeModel(row) {
22
+ const id = row && typeof row.id === 'string' ? row.id : '';
23
+ return id.startsWith('openrouter/') && id.endsWith(':free');
24
+ }
25
+
26
+ /** @param {Array} catalog @returns {Array} free rows, sorted by vendor then id */
27
+ function listFreeModels(catalog) {
28
+ const rows = (Array.isArray(catalog) ? catalog : []).filter(isFreeModel);
29
+ return rows.sort((a, b) => {
30
+ const va = a.id.split('/')[1] || '';
31
+ const vb = b.id.split('/')[1] || '';
32
+ return va === vb ? a.id.localeCompare(b.id) : va.localeCompare(vb);
33
+ });
34
+ }
35
+
36
+ /** @param {Array} catalog @param {number} n @returns {Array} ≤n free rows, one per vendor */
37
+ function suggestFreeCouncil(catalog, n = 3) {
38
+ const out = [];
39
+ const seenVendors = new Set();
40
+ for (const row of listFreeModels(catalog)) {
41
+ const vendor = row.id.split('/')[1] || '';
42
+ if (seenVendors.has(vendor)) { continue; }
43
+ seenVendors.add(vendor);
44
+ out.push(row);
45
+ if (out.length >= n) { break; }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ module.exports = { isFreeModel, listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS };
@@ -90,4 +90,19 @@ function sumWaveUsage(legs) {
90
90
  return { tokens, cost: { amount: anyAmount ? amount : null, currency: 'USD', source, reportedLegs, estimatedLegs, unpricedLegs } };
91
91
  }
92
92
 
93
- module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage };
93
+ /**
94
+ * Render a resolved cost object for humans. Never invents precision: a null
95
+ * amount is '—' (or '?' when the source is explicitly 'unknown'); estimated /
96
+ * mixed costs are marked with '~' so they can't be read as authoritative.
97
+ * @param {{amount:number|null, source:string}|null|undefined} cost
98
+ * @returns {string}
99
+ */
100
+ function formatCost(cost) {
101
+ if (!cost || cost.amount === null || cost.amount === undefined) {
102
+ return cost && cost.source === 'unknown' ? '?' : '—';
103
+ }
104
+ const dollars = cost.amount < 1 ? `$${cost.amount.toFixed(4)}` : `$${cost.amount.toFixed(2)}`;
105
+ return (cost.source === 'estimated' || cost.source === 'mixed') ? `~${dollars}` : dollars;
106
+ }
107
+
108
+ module.exports = { emptyUsageTotals, sumPerMessageUsage, lookupPricing, resolveLegCost, resolveUsage, sumWaveUsage, formatCost };