amicus 4.9.7 → 4.10.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 (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/README.md +2 -1
  4. package/bin/amicus.js +5 -0
  5. package/docs/ROADMAP.md +33 -5
  6. package/docs/architecture-map.md +41 -6
  7. package/docs/configuration.md +14 -8
  8. package/docs/council.md +140 -3
  9. package/docs/usage.md +29 -6
  10. package/electron/ipc-setup.js +6 -9
  11. package/electron/setup-ui-alias-groups.js +29 -124
  12. package/package.json +1 -1
  13. package/schemas/council-verdict.schema.json +3 -1
  14. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  15. package/src/cli-council-run-tools.js +168 -0
  16. package/src/cli-handlers-council-run.js +6 -6
  17. package/src/cli-handlers.js +8 -1
  18. package/src/cli.js +34 -1
  19. package/src/council/briefings-chair.js +1 -1
  20. package/src/council/briefings-task.js +11 -5
  21. package/src/council/briefings.js +25 -7
  22. package/src/council/report-lost-rows.js +89 -0
  23. package/src/council/report-md.js +3 -1
  24. package/src/council/report.js +3 -2
  25. package/src/council/run-degrade.js +22 -1
  26. package/src/council/run-finish.js +23 -1
  27. package/src/council/run-launch.js +33 -4
  28. package/src/council/run-retry-launch.js +9 -4
  29. package/src/council/run-retry.js +3 -0
  30. package/src/council/run-seat-tools-verify.js +296 -0
  31. package/src/council/run-seat-tools.js +274 -0
  32. package/src/council/run-server.js +41 -6
  33. package/src/council/run-stage1-launch.js +8 -3
  34. package/src/council/run.js +21 -21
  35. package/src/council/seat-tools.js +299 -0
  36. package/src/council/verdict-seats-reviewed.js +76 -6
  37. package/src/headless.js +136 -6
  38. package/src/mcp-council-pack-map.js +24 -0
  39. package/src/mcp-council-run.js +17 -15
  40. package/src/mcp-server.js +2 -2
  41. package/src/mcp-tools.js +15 -4
  42. package/src/opencode-client.js +26 -0
  43. package/src/pack/pack-validate.js +3 -1
  44. package/src/prompt-builder.js +2 -2
  45. package/src/sidecar/aliases-review-gate.js +65 -0
  46. package/src/sidecar/aliases-review-prompt.js +91 -0
  47. package/src/sidecar/aliases-review-render.js +116 -0
  48. package/src/sidecar/aliases-review.js +298 -0
  49. package/src/sidecar/aliases.js +279 -0
  50. package/src/sidecar/fanout.js +7 -1
  51. package/src/sidecar/heartbeat.js +46 -0
  52. package/src/sidecar/models.js +20 -7
  53. package/src/sidecar/session-utils.js +7 -34
  54. package/src/sidecar/setup.js +20 -18
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/alias-groups.js +128 -0
  57. package/src/utils/alias-proposals.js +151 -0
  58. package/src/utils/alias-resolver.js +1 -1
  59. package/src/utils/alias-state.js +88 -0
  60. package/src/utils/alias-store.js +65 -0
  61. package/src/utils/config.js +10 -5
  62. package/src/utils/degrade.js +8 -0
  63. package/src/utils/model-id-siblings.js +106 -0
  64. package/src/utils/model-validator.js +1 -1
  65. package/src/utils/quick-picks.js +13 -32
  66. package/src/utils/text-sanitize.js +27 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * @module sidecar/aliases-review
3
+ * `amicus aliases --review` (#238 §4): a numbered readline picker over the
4
+ * engine's proposals — no copy-paste anywhere. Accept writes the chosen id and
5
+ * the encoding decides the state (Q4): the shipped pin → `removeAlias`
6
+ * (follows); anything else → `addAlias` (pinned). Without a TTY it refuses
7
+ * loudly (Q2): the list, one reason line, exit 1. The §5 WRITE gate lives
8
+ * here: accepting a catalog-vouched id needs a fresh catalog (24 h); `follow`
9
+ * is exempt because it removes a key. Screen text is aliases-review-render.js
10
+ * and the readline prompt is aliases-review-prompt.js (both split out to hold
11
+ * the 300-line gate). Every alias/id/key written to the terminal — including
12
+ * a caught `err.message`, via `collapseExcerpt` (a sentence, not an id) —
13
+ * rides the house sanitizer first (`utils/text-sanitize.js`, #249 r2 C4).
14
+ *
15
+ * Fix round 1: a missing cache reads as its own banner, never a bogus
16
+ * multi-thousand-day `ageLabel`; an aborted prompt — Ctrl-D/EOF (readline's
17
+ * `close`) or, IN A RAW-MODE TERMINAL (stdout a TTY), Ctrl-C (readline's own
18
+ * `SIGINT`, #249 r2 D1) — rejects the pending `ask` with a `REVIEW_ABORTED`
19
+ * sentinel rather than silently exiting 0 (piped stdout: `terminal` defaults
20
+ * false, so Ctrl-C is then the ordinary process signal — #249 r2 review
21
+ * F6/F9); every config write is caught per-call so a failure reports and
22
+ * re-shows the menu; typing the shipped id into "choose another" follows unconditionally
23
+ * (Q4's encoding, #249 r2 A1/C2) rather than pinning a redundant copy or
24
+ * consulting either gate; a taken notable name gets a numeric suffix
25
+ * (`freeSuffix`), deliberately not `deriveFreeAlias`'s `free-` naming,
26
+ * which is for free-tier council seeds.
27
+ * Fix round 2 (#249 r1) gate helpers (§5-gated "choose another", clock-skew
28
+ * freshness, a throwing `readCache`) live in aliases-review-gate.js.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const { DEFAULT_MAX_AGE_MS } = require('../utils/model-catalog');
34
+ const { stripGatewayPrefix } = require('../utils/curated-models');
35
+ const { gatedCatalogIds } = require('../utils/alias-proposals');
36
+ const { safeFragment, collapseExcerpt } = require('../utils/text-sanitize');
37
+ const { menuFor, menuLineText, renderScreen, refreshingCatalogLine } = require('./aliases-review-render');
38
+ const { classifyTypedId, notInCatalogLine, notVerifiedLine, staleCatalogBanner } = require('./aliases-review-gate');
39
+
40
+ /**
41
+ * Real-CLI collaborators. Requires are lazy/function-scoped (not top-level)
42
+ * because `aliases.js :: handleAliases` requires THIS module to dispatch
43
+ * `--review` — a top-level `require('./aliases')` here would be a load-time
44
+ * cycle.
45
+ * @returns {object} collaborators for `runReview` when the caller supplies none
46
+ */
47
+ function defaultDeps() {
48
+ const base = require('./aliases');
49
+ const d = base.loadDeps();
50
+ return {
51
+ ...d,
52
+ isTTY: !!process.stdin.isTTY,
53
+ write: (s) => process.stdout.write(s),
54
+ stderr: (s) => process.stderr.write(s),
55
+ collectAliasView: (opts) => base.collectAliasView(opts, d),
56
+ renderAliasList: (view) => base.renderAliasList(view, d.groupAliases),
57
+ addAlias: require('./setup').addAlias,
58
+ removeAlias: require('../utils/alias-store').removeAlias,
59
+ recordDismissal: require('../utils/alias-store').recordDismissal,
60
+ effectiveAliasNames: () => new Set(Object.keys(d.config.getEffectiveAliases())),
61
+ now: () => Date.now(),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The §5 WRITE gate (mirrors doctor-alias-check.js's unexported
67
+ * `isCatalogFresh`). R3: `age >= 0` is required too, so a future `fetchedAt`
68
+ * (clock skew) is explicitly not fresh rather than indefinitely so.
69
+ * @returns {boolean} true when `fetchedAt` is a number, not in the future, and no older than 24h
70
+ */
71
+ function isFresh(fetchedAt, now) {
72
+ return typeof fetchedAt === 'number' && (now - fetchedAt) >= 0 && (now - fetchedAt) <= DEFAULT_MAX_AGE_MS;
73
+ }
74
+
75
+ /** @returns {boolean} true when two ids name the same model once gateway prefixes are normalized */
76
+ function sameModel(a, b) {
77
+ return typeof a === 'string' && typeof b === 'string' && stripGatewayPrefix(a) === stripGatewayPrefix(b);
78
+ }
79
+
80
+ /** @returns {string} `base`, or the first `${base}-2`, `${base}-3`, … not already in `taken` */
81
+ function freeSuffix(base, taken) {
82
+ if (!taken.has(base)) { return base; }
83
+ let n = 2;
84
+ while (taken.has(`${base}-${n}`)) { n += 1; }
85
+ return `${base}-${n}`;
86
+ }
87
+
88
+ /**
89
+ * Accept one candidate. `follow` unpins (`removeAlias` — the encoding IS the
90
+ * state, Q4); `notable` pins under a numeric-suffixed name when its suggested
91
+ * alias is already taken; anything else pins the alias straight to the
92
+ * candidate id. Any write failure is caught here (M2) so the caller can
93
+ * re-show the menu instead of crashing the review.
94
+ * @returns {boolean} true on a successful write
95
+ */
96
+ function acceptCandidate(p, c, d) {
97
+ try {
98
+ if (c.why === 'follow') {
99
+ d.removeAlias(p.alias);
100
+ d.write(` ✓ ${safeFragment(p.alias)} now follows the shipped recommendation (${safeFragment(c.id)})\n`);
101
+ return true;
102
+ }
103
+ if (c.why === 'notable') {
104
+ const name = freeSuffix(p.alias, d.effectiveAliasNames());
105
+ d.addAlias(name, c.id);
106
+ d.write(` ✓ ${safeFragment(name)} → ${safeFragment(c.id)} (pinned)\n`);
107
+ return true;
108
+ }
109
+ d.addAlias(p.alias, c.id);
110
+ d.write(` ✓ ${safeFragment(p.alias)} → ${safeFragment(c.id)} (pinned)\n`);
111
+ return true;
112
+ } catch (err) {
113
+ d.write(` could not write: ${collapseExcerpt(err.message)}\n`);
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The "choose another" sub-flow: a free-text model id. Typing the shipped id
120
+ * (M3, #249 r2 A1/C2) IS the menu's `follow` action, checked FIRST and exempt
121
+ * from BOTH gates below, even when the catalog is stale or lacks the shipped
122
+ * id -- the numbered `follow` item is offered regardless of the catalog.
123
+ * Anything else is checked against the §5 display gate (R2 — the SAME
124
+ * `gatedCatalogIds` the menu's own candidates come from) then the freshness
125
+ * gate; a write failure (M2) is a cancel/refusal.
126
+ * @returns {Promise<'accepted'|'cancel'|'refused'|'error'>}
127
+ */
128
+ async function chooseAnother(p, ctx) {
129
+ const { d, fresh, allCatalogIds, gatedIds, ask } = ctx;
130
+ for (;;) {
131
+ const raw = await ask(' model id (provider/model), blank to cancel: ');
132
+ const ans = String(raw || '').trim();
133
+ if (!ans) { return 'cancel'; }
134
+ // Mutant GATEFIRST: move this below the two gates and the R1 tests go red.
135
+ const follows = !!(p.shipped && sameModel(ans, p.shipped));
136
+ if (!follows) {
137
+ const status = classifyTypedId(ans, allCatalogIds, gatedIds);
138
+ if (status === 'unknown') { d.write(notInCatalogLine(ans)); continue; }
139
+ if (status === 'ungated') { d.write(notVerifiedLine(ans)); continue; }
140
+ if (!fresh) {
141
+ d.write(' cannot accept: the catalog is not fresh (see above)\n');
142
+ return 'refused';
143
+ }
144
+ }
145
+ try {
146
+ if (follows) {
147
+ d.removeAlias(p.alias);
148
+ d.write(` ✓ ${safeFragment(p.alias)} now follows the shipped recommendation (${safeFragment(ans)})\n`);
149
+ } else {
150
+ d.addAlias(p.alias, ans);
151
+ d.write(` ✓ ${safeFragment(p.alias)} → ${safeFragment(ans)} (pinned)\n`);
152
+ }
153
+ return 'accepted';
154
+ } catch (err) {
155
+ d.write(` could not write: ${collapseExcerpt(err.message)}\n`);
156
+ return 'error';
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Drive one proposal's screen + menu loop until it is accepted, skipped, or
163
+ * dismissed. A refused accept (stale catalog), a write failure, or a
164
+ * cancelled/refused "choose another" redisplays this SAME proposal's menu
165
+ * rather than advancing. A menu answer must be all-digits before it is ever
166
+ * handed to `Number` (M6).
167
+ * @returns {Promise<'accepted'|'skipped'|'dismissed'>}
168
+ */
169
+ async function reviewOne(p, i, n, ctx) {
170
+ const { d, fresh, ask } = ctx;
171
+ const items = menuFor(p);
172
+ d.write(renderScreen(p, i, n, items));
173
+ for (;;) {
174
+ const raw = await ask(' > ');
175
+ const trimmed = String(raw || '').trim();
176
+ const choice = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
177
+ if (!Number.isInteger(choice) || choice < 1 || choice > items.length) {
178
+ d.write(` choose 1-${items.length}\n`);
179
+ continue;
180
+ }
181
+ const item = items[choice - 1];
182
+ if (item.action === 'skip') { return 'skipped'; }
183
+ if (item.action === 'dismiss') {
184
+ try {
185
+ d.recordDismissal(p.dismissKey);
186
+ d.write(` dismissed ${safeFragment(p.dismissKey)}\n`);
187
+ return 'dismissed';
188
+ } catch (err) {
189
+ d.write(` could not write: ${collapseExcerpt(err.message)}\n`);
190
+ d.write(menuLineText(items) + '\n');
191
+ continue;
192
+ }
193
+ }
194
+ if (item.action === 'choose') {
195
+ const outcome = await chooseAnother(p, ctx);
196
+ if (outcome === 'accepted') { return 'accepted'; }
197
+ d.write(menuLineText(items) + '\n'); // 'cancel', 'refused' or 'error': same proposal's menu again
198
+ continue;
199
+ }
200
+ if (!fresh && item.candidate.why !== 'follow') {
201
+ d.write(' cannot accept: the catalog is not fresh (see above)\n');
202
+ d.write(menuLineText(items) + '\n');
203
+ continue;
204
+ }
205
+ if (acceptCandidate(p, item.candidate, d)) { return 'accepted'; }
206
+ d.write(menuLineText(items) + '\n');
207
+ }
208
+ }
209
+
210
+ /**
211
+ * `amicus aliases --review` entry point.
212
+ * @param {object} args parsed CLI args (the --json/--quiet argument-error
213
+ * check happens in aliases.js before this is ever called)
214
+ * @param {object} [deps] injectable collaborators — see the module docblock;
215
+ * defaults to real I/O (readline over stdin/stdout) when omitted
216
+ * @returns {Promise<number>} 1 when refused for lacking a TTY or interrupted, else 0
217
+ */
218
+ async function runReview(args, deps) {
219
+ // F4b: merge (not replace), so a test can inject only the members it cares
220
+ // about and let every other collaborator run for real against the hermetic
221
+ // scratch config -- additive, so every existing deps-object test still works.
222
+ const d = { ...defaultDeps(), ...(deps || {}) };
223
+ let prompt = null;
224
+ let ask = d.ask; // M6: kept local, never written back onto `d`
225
+ try {
226
+ const isTTY = d.isTTY ?? !!process.stdin.isTTY;
227
+ if (!isTTY) {
228
+ const view = await d.collectAliasView({ maxAgeMs: Number.POSITIVE_INFINITY });
229
+ d.write(d.renderAliasList(view));
230
+ d.stderr('aliases --review is interactive: run it in a terminal, or use `amicus aliases --json` for machine output\n');
231
+ return 1;
232
+ }
233
+ if (!ask) {
234
+ // Lazy require, same as `./aliases` above -- but for locality, not a
235
+ // cycle: aliases-review-prompt.js has no require edge back to this
236
+ // file, so a top-level require would be equally safe here.
237
+ const { createPrompt } = require('./aliases-review-prompt');
238
+ prompt = createPrompt();
239
+ ask = prompt.ask;
240
+ }
241
+ // Minor (spec §4): name the inline refresh wait so it doesn't read as a
242
+ // hang. R7: a throwing readCache (disk error, corrupt cache) drops this
243
+ // best-effort banner rather than crashing the review.
244
+ if (typeof d.readCache === 'function') {
245
+ try { const line = refreshingCatalogLine(d.readCache(), Date.now()); if (line) { d.write(line); } }
246
+ catch { /* best-effort banner only */ }
247
+ }
248
+ const view = await d.collectAliasView({});
249
+ // F1: no catalog at all means no proposal was ever judged -- that is not
250
+ // the same fact as "judged them all, nothing to review" (below), so it
251
+ // gets its own refusal, before that check ever runs.
252
+ if (!view.catalogAvailable) {
253
+ d.write(' no catalog — cannot review; run amicus models --refresh\n');
254
+ return 1;
255
+ }
256
+ const now = (d.now || Date.now)();
257
+ const fetchedAt = view.catalogInfo && view.catalogInfo.fetchedAt;
258
+ const fresh = isFresh(fetchedAt, now);
259
+ const proposals = Array.isArray(view.proposals) ? view.proposals : [];
260
+ if (proposals.length === 0) {
261
+ d.write(` Nothing to review — ${(view.rows || []).length} aliases, all following or up to date.\n`);
262
+ return 0;
263
+ }
264
+ // M5: the stale/no-cache/clock-skew banner only ever prints once there is
265
+ // something to act on — a "nothing to review" run never mentions the catalog.
266
+ if (!fresh) { d.write(staleCatalogBanner(fetchedAt, now)); }
267
+ let accepted = 0;
268
+ let skipped = 0;
269
+ let dismissed = 0;
270
+ // R2: computed once for the whole run (the catalog view is fixed for the
271
+ // session) so `chooseAnother` never re-derives them per keystroke.
272
+ const catalogModels = Array.isArray(view.catalogInfo && view.catalogInfo.models) ? view.catalogInfo.models : [];
273
+ const allCatalogIds = new Set(catalogModels.map(m => m && m.id).filter(Boolean));
274
+ const gatedIds = new Set(gatedCatalogIds(view.catalogInfo));
275
+ const ctx = { d, fresh, ask, allCatalogIds, gatedIds };
276
+ try {
277
+ for (let i = 0; i < proposals.length; i++) {
278
+ const outcome = await reviewOne(proposals[i], i, proposals.length, ctx);
279
+ if (outcome === 'accepted') { accepted++; }
280
+ else if (outcome === 'dismissed') { dismissed++; }
281
+ else { skipped++; }
282
+ }
283
+ } catch (err) {
284
+ if (err && err.code === 'REVIEW_ABORTED') {
285
+ d.write(` review interrupted — ${accepted} accepted, ${skipped} skipped, ${dismissed} dismissed so far\n`);
286
+ return 1;
287
+ }
288
+ throw err;
289
+ }
290
+ const n = proposals.length;
291
+ d.write(` Reviewed ${n} proposal${n === 1 ? '' : 's'}: ${accepted} accepted, ${skipped} skipped, ${dismissed} dismissed.\n`);
292
+ return 0;
293
+ } finally {
294
+ if (prompt) { prompt.close(); }
295
+ }
296
+ }
297
+
298
+ module.exports = { runReview };
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @module sidecar/aliases
3
+ * `amicus aliases` (#238 D4) — the user's alias map as a standing command.
4
+ *
5
+ * amicus aliases list: following / pinned, grouped by vendor
6
+ * amicus aliases --review picker over every proposal (aliases-review.js)
7
+ * amicus aliases --json versioned document: rows + proposals
8
+ *
9
+ * The LIST reads the catalog CACHE at any age and never networks (§5 display
10
+ * gate); the picker refreshes inline when the cache is stale (write gate).
11
+ * Every form normalizes the config on entry (D6), best-effort.
12
+ *
13
+ * #249 r2 C4: `renderAliasList`'s alias names, ids and vendor-group labels
14
+ * (an unmapped vendor's label is `titleCaseVendor` of a config VALUE's
15
+ * segment — still third-party, per review F1), and the typed name in
16
+ * `handleUnpin`'s messages, are quoted onto a terminal and ride `safeFragment`
17
+ * (the house sanitizer, `utils/text-sanitize.js`) — the fragment, never the
18
+ * composed line, per `alias-shadow.js :: formatAliasShadow`'s rule. A caught
19
+ * `err.message` is a sentence, not an id, so it rides `collapseExcerpt` at
20
+ * the house default cap instead (the `describeThrown` precedent).
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const { SCHEMA_VERSION } = require('../utils/result-schema-version');
26
+ const { DEFAULT_MAX_AGE_MS } = require('../utils/model-catalog');
27
+ const { stripGatewayPrefix } = require('../utils/curated-models');
28
+ const { safeFragment, collapseExcerpt } = require('../utils/text-sanitize');
29
+
30
+ /** @returns {object} this module's collaborators, gathered so a caller can override them in tests */
31
+ function loadDeps() {
32
+ const config = require('../utils/config');
33
+ return {
34
+ config,
35
+ normalizeAliases: require('../utils/alias-state').normalizeAliases,
36
+ listAliasRows: require('../utils/alias-state').listAliasRows,
37
+ buildAliasProposals: require('../utils/alias-proposals').buildAliasProposals,
38
+ readDismissals: require('../utils/alias-store').readDismissals,
39
+ getCatalogInfo: require('../utils/model-catalog').getCatalogInfo,
40
+ readCache: require('../utils/model-catalog').readCache,
41
+ groupAliases: require('../utils/alias-groups').groupAliases,
42
+ };
43
+ }
44
+
45
+ /** @returns {boolean} true when an own value of `aliases` is not a non-empty string -- saveConfig's own stripper would remove it */
46
+ function hasStrippableAliasValue(aliases) {
47
+ return Object.keys(aliases).some(k => typeof aliases[k] !== 'string' || aliases[k].length === 0);
48
+ }
49
+
50
+ /**
51
+ * Normalize on entry (D6), best-effort: a read-only config dir never blocks a
52
+ * listing — the in-memory normalized view is used and the failure announced.
53
+ * Also fires on a non-string value (#249 r1 R8a): `normalizeAliases` only
54
+ * drops a value equal to the shipped default, so a garbage value would
55
+ * otherwise sit on disk forever -- `saveConfig`'s own stripper removes it,
56
+ * with its own Notice.
57
+ * @returns {object} the user alias map after normalization
58
+ */
59
+ function normalizeOnEntry(d) {
60
+ const cfg = d.config.loadConfig();
61
+ const defaults = d.config.getDefaultAliases();
62
+ if (!cfg || !cfg.aliases || typeof cfg.aliases !== 'object') { return {}; }
63
+ const probe = d.normalizeAliases(cfg.aliases, defaults);
64
+ if (probe.removed.length > 0 || hasStrippableAliasValue(cfg.aliases)) {
65
+ try { d.config.saveConfig(cfg); } // saveConfig prints the Notices
66
+ catch (err) { process.stderr.write(`Notice: could not normalize aliases (${collapseExcerpt(err.message)}) — keys left on disk; every alias still resolves to the same id\n`); }
67
+ }
68
+ return probe.aliases;
69
+ }
70
+
71
+ /**
72
+ * @param {{maxAgeMs?: number}} [opts] `Number.POSITIVE_INFINITY` = cache only
73
+ * @returns {Promise<{rows: Array, proposals: Array, catalogInfo: object, catalogAvailable: boolean}>}
74
+ */
75
+ async function collectAliasView(opts = {}, d = loadDeps()) {
76
+ const userAliases = normalizeOnEntry(d);
77
+ const defaults = d.config.getDefaultAliases();
78
+ let catalogInfo = { models: [], fetchedAt: null, providerFailures: [] };
79
+ try {
80
+ if (opts.maxAgeMs === Number.POSITIVE_INFINITY) {
81
+ // Display gate (§5): the LIST never networks, even to fill an empty or
82
+ // v1 cache — read whatever is on disk, verbatim, with no freshness
83
+ // check at all. `getCatalogInfo` cannot be reused here: it always
84
+ // calls `getCatalog`, which refreshes (a real fetch) the moment
85
+ // `readCache()` returns null, regardless of `maxAgeMs`.
86
+ const c = d.readCache();
87
+ catalogInfo = {
88
+ models: (c && Array.isArray(c.models)) ? c.models : [],
89
+ fetchedAt: c && typeof c.fetchedAt === 'number' ? c.fetchedAt : null,
90
+ providerFailures: (c && Array.isArray(c.providerFailures)) ? c.providerFailures : [],
91
+ };
92
+ } else {
93
+ // The picker's default-age path (Task 8): a stale cache refreshes inline.
94
+ catalogInfo = await d.getCatalogInfo(opts.maxAgeMs === undefined ? {} : { maxAgeMs: opts.maxAgeMs });
95
+ }
96
+ } catch (err) { process.stderr.write(`Notice: catalog unavailable (${collapseExcerpt(err.message)}) — no proposals\n`); }
97
+ const rows = d.listAliasRows(userAliases, defaults);
98
+ const proposals = d.buildAliasProposals({ userAliases, defaults, catalogInfo, dismissed: d.readDismissals() });
99
+ return { rows, proposals, catalogInfo, catalogAvailable: (catalogInfo.models || []).length > 0 };
100
+ }
101
+
102
+ /**
103
+ * F2: the per-row flag names the SPECIFIC reason a proposal exists, instead
104
+ * of a blanket "newer available" that was wrong for two of the three kinds —
105
+ * an ahead-of-shipped pin with no sibling only differs from the shipped pin,
106
+ * and a stale pin with no catalog match at all is gone from the catalog
107
+ * outright, neither of which is "newer available".
108
+ * @param {string[]} reasons a proposal's `reasons` array
109
+ * @returns {string} the row suffix, or '' when called with no reasons
110
+ */
111
+ function rowFlag(reasons) {
112
+ if (!reasons || reasons.length === 0) { return ''; }
113
+ if (reasons.includes('newer-sibling')) { return ' ⚠ newer available'; }
114
+ if (reasons.includes('stale')) { return ' ⚠ gone from catalog'; }
115
+ return ' differs from shipped';
116
+ }
117
+
118
+ /**
119
+ * R4 (#249 r1 C1): a pinned curated row that names the shipped model under a
120
+ * DIFFERENT gateway form gets no proposal at all -- alias-proposals.js's own
121
+ * `sameModel` already treats it as identical to the shipped pin, so it is
122
+ * neither stale nor "differing". Without this note it renders identically
123
+ * to an arbitrary custom pin, losing the fact that it is the shipped
124
+ * recommendation in a different form. Truthful transparency, not a warning.
125
+ * @param {{state:string, curated:boolean, id:string, shipped:string|null}} r
126
+ * @returns {string} the row suffix, or '' when the note does not apply
127
+ */
128
+ function sameGatewayNote(r) {
129
+ if (r.state !== 'pinned' || !r.curated || typeof r.id !== 'string' || typeof r.shipped !== 'string') { return ''; }
130
+ if (r.id === r.shipped) { return ''; }
131
+ return stripGatewayPrefix(r.id) === stripGatewayPrefix(r.shipped) ? ' same model as shipped, other gateway' : '';
132
+ }
133
+
134
+ /** @param {{rows: Array, proposals: Array}} view @param {Function} [groupAliases] injectable for tests; defaults to loadDeps().groupAliases so the published `(view) => string` signature works standalone @returns {string} */
135
+ function renderAliasList(view, groupAliases = loadDeps().groupAliases) {
136
+ const byAlias = new Map(view.rows.map(r => [r.alias, r]));
137
+ const proposalByAlias = new Map(view.proposals.map(p => [p.alias, p]));
138
+ const map = { __proto__: null };
139
+ for (const r of view.rows) { map[r.alias] = r.id; }
140
+ // #249 r2 C4: width is computed from the SANITIZED name -- a bidi/ANSI
141
+ // fragment stripped at render time must not skew the column alignment of
142
+ // every other row's padding.
143
+ const width = Math.max(6, ...view.rows.map(r => safeFragment(r.alias).length));
144
+ const lines = [];
145
+ for (const g of groupAliases(map)) {
146
+ // F1 (#249 r2 review, C4 residual): for a vendor NOT in ALIAS_VENDOR_LABELS,
147
+ // `alias-groups.js :: vendorLabel` title-cases the raw vendor segment of a
148
+ // config VALUE (`aliasVendorOf`) rather than mapping it to house text --
149
+ // that is still third-party data, unlike the ~30 mapped labels. Sanitized
150
+ // HERE, not inside `vendorLabel`: that helper also renders into the
151
+ // Electron setup UI's HTML context (`electron/setup-ui-alias-groups.js`),
152
+ // out of scope for this terminal-only house rule.
153
+ lines.push(` ${safeFragment(g.label)}`);
154
+ for (const key of g.keys) {
155
+ const r = byAlias.get(key);
156
+ const p = proposalByAlias.get(key);
157
+ const flag = p ? rowFlag(p.reasons) : sameGatewayNote(r);
158
+ lines.push(` ${safeFragment(key).padEnd(width)} → ${safeFragment(r.id).padEnd(44)} ${r.state}${flag}`);
159
+ }
160
+ }
161
+ lines.push('');
162
+ // F1: an unavailable catalog cannot be checked for updates at all -- that is
163
+ // a different fact from "checked, nothing found" and must not print the
164
+ // reassuring "nothing to review" line.
165
+ if (!view.catalogAvailable) {
166
+ lines.push(' catalog unavailable — cannot check for updates (amicus models --refresh)');
167
+ return lines.join('\n') + '\n';
168
+ }
169
+ const n = view.proposals.length;
170
+ lines.push(n === 0
171
+ ? ' nothing to review — amicus aliases --review'
172
+ : ` ${n} to review — amicus aliases --review`);
173
+ // F1: the catalog is available but stale -- name its age so a user who
174
+ // never runs --review still learns the background refresh isn't keeping up.
175
+ const fetchedAt = view.catalogInfo && view.catalogInfo.fetchedAt;
176
+ if (typeof fetchedAt === 'number' && (Date.now() - fetchedAt) > DEFAULT_MAX_AGE_MS) {
177
+ const days = Math.floor((Date.now() - fetchedAt) / DEFAULT_MAX_AGE_MS);
178
+ lines.push(` (catalog is ${days} day${days === 1 ? '' : 's'} old — amicus models --refresh)`);
179
+ }
180
+ return lines.join('\n') + '\n';
181
+ }
182
+
183
+ /** @returns {object} the `--json` document (fields only ever ADDED within SCHEMA_VERSION) */
184
+ function buildAliasesDoc(view) {
185
+ return {
186
+ schemaVersion: SCHEMA_VERSION,
187
+ type: 'aliases',
188
+ catalogAvailable: view.catalogAvailable,
189
+ catalogFetchedAt: view.catalogInfo.fetchedAt || null,
190
+ aliasCount: view.rows.length,
191
+ aliases: view.rows,
192
+ proposalCount: view.proposals.length,
193
+ proposals: view.proposals,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * `amicus aliases --unpin <name>` (#238 F6, R1): "unpin" and "delete" are one
199
+ * operation -- remove the key -- whose meaning is decided by whether the
200
+ * name is curated (D1). The name is trimmed before every use -- for the
201
+ * `removeAlias` lookup, the `isCurated` check and both success messages --
202
+ * so a padded name neither crashes nor mis-reports which branch fired
203
+ * (#249 r1 R1). Blank/whitespace/literal-'null' names are refused here, and
204
+ * `removeAlias` is called under try/catch so any other throw (its own name
205
+ * guard included) becomes a clean exit 1, never an uncaught crash.
206
+ *
207
+ * R4 (#249 r2 D2): refused BEFORE any write when `name` is also
208
+ * `config.default` and NOT curated -- deleting it would leave the default
209
+ * dangling on a key that no longer resolves (`resolveModel` throws), and
210
+ * silently doing that fails the product principle (never a silent dangling
211
+ * default) as hard as a crash. Precedent: `cli-handlers-provider.js ::
212
+ * doRemove` re-points `config.default` when a provider goes away; here the
213
+ * user is deleting one alias on purpose, so refusing and naming the fix is
214
+ * the transparent choice instead. A CURATED default is unaffected -- it
215
+ * keeps resolving from the shipped table after the unpin, same as any other
216
+ * curated unpin. `config.default` may also be a bare model id rather than
217
+ * an alias name (`start-helpers.js` resolves either); the guard compares
218
+ * against the literal `name` argument, so a default that merely happens to
219
+ * RESOLVE to the same id as this alias is not what it's checking.
220
+ * @param {*} rawName whatever `args.unpin` parsed to
221
+ * @returns {number} exit code
222
+ */
223
+ function handleUnpin(rawName) {
224
+ const name = typeof rawName === 'string' ? rawName.trim() : '';
225
+ if (!name || name === 'null') {
226
+ process.stderr.write('Error: --unpin requires an alias name\n');
227
+ return 1;
228
+ }
229
+ const { removeAlias } = require('../utils/alias-store');
230
+ const { isCurated } = require('../utils/alias-state');
231
+ const config = require('../utils/config');
232
+ const defaults = config.getDefaultAliases();
233
+ const cfg = config.loadConfig();
234
+ if (cfg && cfg.default === name && !isCurated(name, defaults)) {
235
+ process.stderr.write(`Error: '${safeFragment(name)}' is your default model (config.default) — pick another default first (amicus setup)\n`);
236
+ return 1;
237
+ }
238
+ let removed;
239
+ try {
240
+ removed = removeAlias(name);
241
+ } catch (err) {
242
+ process.stderr.write(`Error: ${collapseExcerpt(err.message)}\n`);
243
+ return 1;
244
+ }
245
+ if (!removed) {
246
+ process.stderr.write(`Error: '${safeFragment(name)}' is not pinned (see: amicus aliases)\n`);
247
+ return 1;
248
+ }
249
+ process.stdout.write(isCurated(name, defaults)
250
+ ? `✓ ${safeFragment(name)} now follows the shipped recommendation (${defaults[name]})\n`
251
+ : `✓ ${safeFragment(name)} removed\n`);
252
+ return 0;
253
+ }
254
+
255
+ /** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
256
+ async function handleAliases(args) {
257
+ if (args.review && (args.json || args.quiet)) {
258
+ process.stderr.write('Error: --review is interactive; use `amicus aliases --json` for machine output\n');
259
+ return 1;
260
+ }
261
+ if (args.unpin !== undefined) {
262
+ if (args.review || args.json) {
263
+ process.stderr.write('Error: --unpin cannot be combined with --review or --json\n');
264
+ return 1;
265
+ }
266
+ return handleUnpin(args.unpin); // handleUnpin trims/validates (R1): true, 42, '', ' ', 'null' all land the same error
267
+ }
268
+ if (args.review) { return require('./aliases-review').runReview(args); }
269
+ const d = loadDeps();
270
+ const view = await collectAliasView({ maxAgeMs: Number.POSITIVE_INFINITY }, d);
271
+ if (args.json) {
272
+ process.stdout.write(JSON.stringify(buildAliasesDoc(view), null, 2) + '\n');
273
+ return 0;
274
+ }
275
+ process.stdout.write(renderAliasList(view, d.groupAliases));
276
+ return 0;
277
+ }
278
+
279
+ module.exports = { handleAliases, collectAliasView, renderAliasList, buildAliasesDoc, loadDeps };
@@ -46,6 +46,7 @@ const { deriveLegIds } = require('./leg-ids');
46
46
  * to run this wave's legs on. Both or neither. When supplied this wave never
47
47
  * starts a server and never closes one — see the seam comment in step 4.
48
48
  * NOT `client`, which is the client TYPE string on this function.)
49
+ * serverAgents? (spec 2026-09-11 §4: agents to register if this wave starts its own server)
49
50
  * @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
50
51
  */
51
52
  async function runFanout(options) {
@@ -218,7 +219,12 @@ async function runFanout(options) {
218
219
  logger.debug('Using external server (shared server mode)', { waveId, url: server.url });
219
220
  } else {
220
221
  try {
221
- ({ client, server } = await startOpenCodeServer(mcpServers, { models: validated.serverModels || okLegs.map(l => l.model) }));
222
+ ({ client, server } = await startOpenCodeServer(mcpServers, {
223
+ models: validated.serverModels || okLegs.map(l => l.model),
224
+ // Spec 2026-09-11 §4: a wave that starts its own server still needs the
225
+ // council agents registered on it. Spread-guarded: byte-identical otherwise.
226
+ ...(options.serverAgents ? { agents: options.serverAgents } : {}),
227
+ }));
222
228
  } catch (err) {
223
229
  writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
224
230
  return errorWave(waveId, `Failed to start server: ${err.message}`, waveDir);
@@ -0,0 +1,46 @@
1
+ // src/sidecar/heartbeat.js
2
+ 'use strict';
3
+ // HEARTBEAT_INTERVAL + createHeartbeat — moved verbatim from session-utils.js
4
+ // (size-gate split, spec 2026-09-11 §4 PR 2: that file was already at the
5
+ // 300-line ceiling before this task's additions). Zero behavior change; both
6
+ // re-exported from session-utils.js's existing module.exports, so no caller
7
+ // (start.js, resume.js, continue.js, fanout.js, index.js, and their tests)
8
+ // needs to change — see tests/sidecar/session-utils.test.js and
9
+ // tests/sidecar/start.test.js, which exercise these through that re-export.
10
+
11
+ /** Standard heartbeat interval in milliseconds */
12
+ const HEARTBEAT_INTERVAL = 15000;
13
+
14
+ /**
15
+ * Create a heartbeat that writes status to stderr periodically.
16
+ * When sessionDir is provided, includes message count and latest activity.
17
+ *
18
+ * @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
19
+ * @param {string} [sessionDir] - Session directory to read progress from
20
+ * @returns {{ stop: () => void }}
21
+ */
22
+ function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
23
+ const startTime = Date.now();
24
+ const intervalId = setInterval(() => {
25
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
26
+ const mins = Math.floor(elapsed / 60);
27
+ const secs = elapsed % 60;
28
+ const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
29
+
30
+ if (sessionDir) {
31
+ const { readProgress } = require('./progress');
32
+ const progress = readProgress(sessionDir);
33
+ process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
34
+ } else {
35
+ process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
36
+ }
37
+ }, interval);
38
+
39
+ return {
40
+ stop() {
41
+ clearInterval(intervalId);
42
+ }
43
+ };
44
+ }
45
+
46
+ module.exports = { HEARTBEAT_INTERVAL, createHeartbeat };