amicus 4.8.0 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +355 -0
- package/README.md +10 -5
- package/docs/CITATIONS.md +13 -5
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +55 -5
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +39 -2
- package/electron/main.js +46 -3
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui-model.js +99 -9
- package/electron/setup-ui-styles.js +22 -0
- package/electron/setup-ui.js +244 -32
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/live-seats.js +4 -4
- package/electron/workspace-ui/workspace-banners.js +30 -7
- package/electron/workspace-ui/workspace-matrix.js +23 -3
- package/electron/workspace-ui/workspace-seats.js +95 -79
- package/package.json +2 -1
- package/schemas/council-run.schema.json +2 -2
- package/schemas/council-tally.schema.json +17 -1
- package/schemas/council-verdict.schema.json +12 -4
- package/schemas/run.schema.json +6 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +88 -9
- package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
- package/skills/second-opinion/SKILL.md +151 -36
- package/src/cli-council-run-bench.js +98 -6
- package/src/cli-handlers-council-run.js +18 -6
- package/src/cli-handlers-council.js +57 -7
- package/src/cli-handlers-doctor.js +12 -15
- package/src/cli.js +3 -1
- package/src/council/anonymize.js +2 -1
- package/src/council/briefings-chair-task.js +161 -0
- package/src/council/briefings-chair.js +33 -8
- package/src/council/briefings-debate.js +79 -13
- package/src/council/briefings-stage2-task.js +236 -0
- package/src/council/briefings-stage2.js +103 -26
- package/src/council/briefings-task.js +167 -0
- package/src/council/briefings.js +41 -4
- package/src/council/chair-fallback.js +95 -0
- package/src/council/debate.js +38 -21
- package/src/council/findings.js +3 -2
- package/src/council/ledger.js +2 -2
- package/src/council/parse-stage2.js +64 -16
- package/src/council/report-cost.js +61 -0
- package/src/council/report-html.js +26 -4
- package/src/council/report-md.js +30 -2
- package/src/council/report.js +40 -37
- package/src/council/run-assemble.js +21 -6
- package/src/council/run-chair.js +44 -95
- package/src/council/run-debate-revote.js +81 -49
- package/src/council/run-debate.js +51 -34
- package/src/council/run-finish.js +5 -3
- package/src/council/run-retry-keys.js +4 -4
- package/src/council/run-retry-launch.js +4 -4
- package/src/council/run-retry-notes.js +72 -15
- package/src/council/run-stage1-launch.js +4 -4
- package/src/council/run-stage1-rows.js +9 -6
- package/src/council/run-stage2.js +81 -47
- package/src/council/run-stages.js +9 -21
- package/src/council/run-stats-entry.js +46 -1
- package/src/council/run.js +28 -13
- package/src/council/seats.js +2 -2
- package/src/council/stage1-bind.js +3 -2
- package/src/council/verdict-seat-loss.js +124 -0
- package/src/council/verdict.js +108 -99
- package/src/headless.js +256 -49
- package/src/mcp-council-bench.js +64 -3
- package/src/mcp-council-run.js +10 -3
- package/src/mcp-server.js +52 -12
- package/src/mcp-tools.js +41 -5
- package/src/observe/council-legs.js +2 -2
- package/src/opencode-client.js +19 -1
- package/src/pack/pack-forward.js +15 -12
- package/src/pack/pack-resolve.js +1 -1
- package/src/prompt-builder.js +17 -1
- package/src/sidecar/fanout-leg.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models.js +8 -1
- package/src/sidecar/read.js +34 -10
- package/src/sidecar/setup.js +124 -0
- package/src/template/render.js +16 -7
- package/src/utils/alias-audit.js +81 -3
- package/src/utils/alias-shadow-writer.js +220 -0
- package/src/utils/alias-shadow.js +294 -0
- package/src/utils/config.js +1 -1
- package/src/utils/curated-models.js +16 -8
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +149 -0
- package/src/utils/engine-log-parse.js +289 -0
- package/src/utils/engine-log-tail.js +114 -0
- package/src/utils/engine-log.js +250 -0
- package/src/utils/engine-skew-records.js +146 -0
- package/src/utils/engine-skew.js +300 -0
- package/src/utils/gateway-router.js +10 -2
- package/src/utils/model-canonicalization.js +64 -0
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/model-shortlist.js +100 -0
- package/src/utils/provider-default-picker.js +93 -45
- package/src/utils/provider-default-prompt.js +1 -1
- package/src/utils/quick-picks.js +2 -2
- package/src/utils/remediation-hints.js +24 -0
- package/src/utils/result-schema.js +10 -0
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +35 -9
- package/src/workspace/seat-space.js +10 -6
package/docs/usage.md
CHANGED
|
@@ -203,6 +203,7 @@ amicus council run --prompt-file briefing.md --models gemini,glm --chair deepsee
|
|
|
203
203
|
| `--artifact <file>` | File whose content fills `{{artifact}}`/`{{artifact_path}}` (256 KB cap). Requires `--template`. |
|
|
204
204
|
| `--var <k=v>` | Set `{{var.<key>}}`; repeatable. Requires `--template`. |
|
|
205
205
|
| `--tag <t>` | Label this run for `list`/`--search`/`spend --group-by tag` (1-64 chars, `[A-Za-z0-9_-]`; rejected, not cleaned). Every stage's sub-waves (Stage-1, critic/lens solos, Stage-2, chair, debate) carry the same tag on their wave metadata. |
|
|
206
|
+
| `--intent <review\|task>` | The run's intent (v4.9). `review` — the default — is never stored; `task` marks a task-mode run, recorded as `intent: "task"` on `run.json`/`verdict.json` and kept out of the reliability ledger. Over MCP: the `intent` param on `amicus_council_run`, and a hand-assembled `amicus_council_tally` input may carry `meta.intent` the same emit-when-`task` way. What forks stage by stage — and what stays byte-identical — is in [docs/council.md § Task mode](./council.md#task-mode---intent-task). |
|
|
206
207
|
|
|
207
208
|
**Exit codes:** `0` full run · `2` degraded but reportable (fewer than 2 judges, chair failure —
|
|
208
209
|
`overallVerdict: null` — a cost ceiling hit after the tally, or a `--max-cost` ceiling set over a
|
|
@@ -403,7 +404,7 @@ Each stored alias resolves to one of three outcomes:
|
|
|
403
404
|
| Outcome | Example line | Meaning |
|
|
404
405
|
|---------|--------------|---------|
|
|
405
406
|
| `SERVED` | `SERVED: gemini -> openrouter/google/gemini-3.6-flash ($0.0004)` | The model answered; cost shown in parens. |
|
|
406
|
-
| `SILENT` (`accepted-but-silent`) | `SILENT: probetest -> anthropic/claude-opus-4-8 — NO_OUTPUT_BACKSTOP: … (
|
|
407
|
+
| `SILENT` (`accepted-but-silent`) | `SILENT: probetest -> anthropic/claude-opus-4-8 — NO_OUTPUT_BACKSTOP: … (no output within the probe window)` | Nothing arrived at all within the probe's 30 s backstop window — shorter than the ordinary 300 s leg default, and not tunable. ⚠️ `accepted-but-silent` is the CLASSIFICATION's name, not a proven fact about the endpoint: a fired backstop shows only that no output, reasoning or tool call arrived in the window, which a stalled gateway or a dropped connection produces just as readily as a model that accepted and then said nothing. Either way it is the "listed but not actually serving" failure this check exists to catch. |
|
|
407
408
|
| `ERROR` | `ERROR: gpt -> openai/gpt-5.6-terra — 402 Payment Required` | Routing, auth, or provider failure; the raw error is printed. |
|
|
408
409
|
|
|
409
410
|
**Exit code.** The probe's non-served count folds into the same exit code as the static audit — `max(existing exit, min(nonServedCount, 100))` — so a single `SILENT` or `ERROR` fails the check even when every alias is otherwise catalog-fresh. No stored aliases prints `Live probe: no stored aliases to probe` and never affects the exit code. `--json` adds `probe` (the per-alias array) and `probeCount` (its length) to the `alias-audit` document — both additive, `[]`/`0` when `--live` wasn't passed.
|
|
@@ -464,12 +465,31 @@ amicus setup --add-alias fast=google/gemini-2.5-flash # Add/override one alias
|
|
|
464
465
|
the same core fields everywhere: `id`, `model`, `status`, `mode` (`interactive`/`headless`),
|
|
465
466
|
`type` (`run` by default), `parentWave` (`null` unless the row is a fan-out leg), `legCount`
|
|
466
467
|
(`null` unless the row is a wave), and `tag` — the one field that's omitted, not `null`, when the
|
|
467
|
-
session has none. The human-readable CLI table adds a `TAG` column. Council-run rows
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
`briefing` preview plus a `stage` field naming
|
|
468
|
+
session has none. The human-readable CLI table adds a `TAG` column. Council-run rows join the
|
|
469
|
+
listing on **both surfaces** through a separate merge (`src/sidecar/list-council.js`): the
|
|
470
|
+
directory scan itself still skips their `council-<runId>.json` pointer files (a pointer's
|
|
471
|
+
filename fails the session-ID pattern every other row's directory name must match), which is
|
|
472
|
+
exactly why the merge exists. Each merged row carries `type: 'council-run'`, a fixed
|
|
473
|
+
`mode: 'headless'`, and its own 80-char sanitized `briefing` preview plus a `stage` field naming
|
|
474
|
+
whichever stage is currently running; the CLI table renders `council(<stage>)` in its MODEL
|
|
475
|
+
column and re-truncates that 80-char preview to its usual 30-char slice. That merge is scoped
|
|
476
|
+
to the CURRENT project on both surfaces — `--all` included, so under `--all` the session rows
|
|
477
|
+
span every known project while the council rows do not: council runs are found through
|
|
478
|
+
per-project pointer files and there is no cross-project council index to walk. The CLI says
|
|
479
|
+
so at runtime rather than only here — every human-readable `--all` listing ends with
|
|
480
|
+
`council runs: current project only (no cross-project index).`, under the table or right after
|
|
481
|
+
`No amicus sessions found.` when there is no table, and after the `--limit` elision notice when
|
|
482
|
+
one is printed. That line is human-surface only: `--json`
|
|
483
|
+
keeps its shape, on stdout and stderr alike, because no flag can widen the scope it reports.
|
|
484
|
+
If the merge itself cannot run — an unreadable or corrupt council pointer, say — the listing
|
|
485
|
+
still prints the sessions it already had and adds `council runs: unavailable (<reason>)`,
|
|
486
|
+
rather than dropping every council row in silence. It appears with or without `--all` (under
|
|
487
|
+
`--all` it sits just above the scope note), and the reason is the underlying error message,
|
|
488
|
+
sanitised and capped to one line. This one is human-surface only for a *different* reason than
|
|
489
|
+
the scope note: not that nothing could widen it, but that `--json`'s shape is a contract and
|
|
490
|
+
this is prose. The residual that leaves — a `--json` caller reads a well-formed document that
|
|
491
|
+
is silently short — is recorded at the pins in `tests/list-council-merge.test.js`.
|
|
492
|
+
The MCP tool
|
|
473
493
|
also re-sanitizes every other row's `briefing` to that same 80-char cap and, for any row still
|
|
474
494
|
`status: 'running'`, adds live-progress fields (`phase`, `messageCount`, `lastActivityAt`,
|
|
475
495
|
`latestPreview`) — enrichments the CLI table doesn't apply, since it prints the raw 30-char slice
|
|
@@ -481,10 +501,10 @@ at a missing or unreadable project is skipped rather than surfaced as an error.
|
|
|
481
501
|
fan-out wave row reads its full `briefing.md` off disk (falling back to the row's 200-char excerpt
|
|
482
502
|
if that file isn't readable), and a leg row (one spawned by a wave) matches on `id`/`tag` only —
|
|
483
503
|
its briefing is the parent wave's, and matching it there would surface the same wave once per leg
|
|
484
|
-
it spawned.
|
|
485
|
-
written at MCP launch time, or falls back to the portion of
|
|
486
|
-
`--- MATERIAL / BRIEFING ---` (CLI-launched runs only ever have the
|
|
487
|
-
|
|
504
|
+
it spawned. A council-run row's search material (both surfaces, now that the CLI merges council
|
|
505
|
+
rows too) is `briefing.md` written at MCP launch time, or falls back to the portion of
|
|
506
|
+
`briefing-stage1.md` after `--- MATERIAL / BRIEFING ---` (CLI-launched runs only ever have the
|
|
507
|
+
latter file). A bare `--search`
|
|
488
508
|
with no value is a usage error on the CLI. Tag itself is set at launch with `--tag <t>` on
|
|
489
509
|
`start`/`fanout`/`council run` (see those sections above), and is also a dimension for
|
|
490
510
|
`amicus spend --group-by tag`. `amicus continue` and `amicus resume` don't take a `--tag` of
|
|
@@ -518,7 +538,7 @@ $ amicus status demo123 --json
|
|
|
518
538
|
"taskId": "demo123",
|
|
519
539
|
"status": "complete",
|
|
520
540
|
"elapsed": "5m 0s",
|
|
521
|
-
"version": "4.
|
|
541
|
+
"version": "4.9.0",
|
|
522
542
|
"model": "google/gemini-2.5-flash",
|
|
523
543
|
"phase": "terminal"
|
|
524
544
|
}
|
|
@@ -849,6 +869,102 @@ The async pattern is **start → status → read**: `amicus_start` (or `amicus_f
|
|
|
849
869
|
|
|
850
870
|
Session statuses: `running`, `complete`, `aborted`, `crashed`, `error`, `timed-out`, `idle-timeout`
|
|
851
871
|
|
|
872
|
+
### MCP tool parameters
|
|
873
|
+
|
|
874
|
+
Every tool below also takes an optional `project` — an absolute path naming the working directory the call resolves against, auto-detected from the caller's cwd when omitted. `amicus_setup` and `amicus_guide` take no parameters at all; `amicus_council_stats` takes only `project`; and `amicus_spend`'s nine filters are described in the paragraph above. A handful of parameters not listed here are filled in by the calling agent from facts only it can see (the Cowork VM process name, the parent session UUID) rather than chosen by you — they are documented in the tool schema the agent reads, and pinned as such in `tests/mcp-tool-params-docs.test.js`.
|
|
875
|
+
|
|
876
|
+
**`amicus_start`** — spawn a session with another model.
|
|
877
|
+
|
|
878
|
+
- `model` — short alias (`gemini`, `gpt`, `opus`, `deepseek`, …) or a full `provider/model` id. Omitted uses your configured default.
|
|
879
|
+
- `gateway` — routing preference: `auto` (direct-first, the default), `direct`, or `openrouter`.
|
|
880
|
+
- `prompt` — the task briefing: objective, background, files of interest, success criteria.
|
|
881
|
+
- `agent` — OpenCode agent mode (`Chat`, `Plan`, `Build`); see [OpenCode Agent Types](#opencode-agent-types) below.
|
|
882
|
+
- `noUi` — run headless instead of opening the Electron window. Default `false`.
|
|
883
|
+
- `thinking` — reasoning effort (`low` | `medium` | `high`). Default `medium`.
|
|
884
|
+
- `timeout` — headless timeout in minutes (default 15); applies only when `noUi` is true.
|
|
885
|
+
- `contextTurns` — max parent-conversation turns to include. Default 50; the MCP twin of `--context-turns`.
|
|
886
|
+
- `contextSince` — time window for parent context (`30m`, `2h`, `1d`); overrides `contextTurns` when set. The MCP twin of `--context-since`.
|
|
887
|
+
- `contextMaxTokens` — cap on the included context, in tokens. Default 80000.
|
|
888
|
+
- `includeContext` — include the parent conversation at all. Default `true`; set `false` for a self-contained briefing (the MCP twin of `--no-context`).
|
|
889
|
+
- `summaryLength` — fold-summary verbosity: `brief`, `normal` (default), or `verbose`.
|
|
890
|
+
- `windowPosition` — where the interactive window lands: `right` (default), `left`, or `center`.
|
|
891
|
+
- `pack` — [policy pack](#policy-packs) name or path supplying defaults; explicit params still win.
|
|
892
|
+
- `tag` — a 1-64 character label (letters, digits, `_`, `-`) for `list`/`search`/`spend` grouping.
|
|
893
|
+
|
|
894
|
+
**`amicus_status`** — one-shot status. Takes `taskId` (a session or wave id).
|
|
895
|
+
|
|
896
|
+
**`amicus_abort`** — stop a running session. Takes `taskId`.
|
|
897
|
+
|
|
898
|
+
**`amicus_wait`** — block until a run reaches a terminal state, replacing a sleep+poll loop.
|
|
899
|
+
|
|
900
|
+
- `taskId` — the session or wave id to wait on.
|
|
901
|
+
- `waveId` — alias for `taskId` when waiting on a fan-out wave.
|
|
902
|
+
- `timeoutMs` — max wait in milliseconds. Default 50000, capped at 110000 so the call returns before typical MCP client kill windows; on expiry you get `{timedOut: true}`, not an error, and re-call.
|
|
903
|
+
|
|
904
|
+
**`amicus_read`** — read a finished session. Every mode is capped at ~50KB.
|
|
905
|
+
|
|
906
|
+
- `taskId` — the task to read.
|
|
907
|
+
- `mode` — `summary` (default), `conversation`, or `metadata`. Paging is ignored in `metadata`.
|
|
908
|
+
- `offset` — byte offset to start from (0-based). Suppresses the cap and the truncation notice, and takes precedence over `tail`.
|
|
909
|
+
- `limit` — max bytes to return (1-51200). Defaults to the cap.
|
|
910
|
+
- `tail` — return the LAST `limit` bytes instead of the first. Ignored when `offset` is given; it is also the implicit behaviour when content exceeds the cap and neither is set.
|
|
911
|
+
|
|
912
|
+
**`amicus_list`** — list sessions.
|
|
913
|
+
|
|
914
|
+
- `status` — filter by status (`all`, `running`, `complete`, `error`, `aborted`, …). Default: everything.
|
|
915
|
+
- `search` — case-insensitive substring filter over id, tag, and briefing material.
|
|
916
|
+
|
|
917
|
+
**`amicus_resume`** — reopen a finished session. Takes `taskId`, plus `noUi` and `timeout` with the same meanings as on `amicus_start`.
|
|
918
|
+
|
|
919
|
+
**`amicus_continue`** — send a follow-up turn into a previous session.
|
|
920
|
+
|
|
921
|
+
- `taskId` — the session to continue from; `prompt` — the new task description.
|
|
922
|
+
- `model` / `gateway` — override the model or routing for the continuation.
|
|
923
|
+
- `noUi` / `timeout` — headless mode and its timeout, as on `amicus_start`.
|
|
924
|
+
- `contextTurns` / `contextMaxTokens` — how much of the previous session's conversation rides along.
|
|
925
|
+
|
|
926
|
+
**`amicus_fanout`** — same briefing, many models, one wave.
|
|
927
|
+
|
|
928
|
+
- `models` — 1-10 aliases or full ids (2+ for a genuine fan-out). Mutually exclusive with `council`.
|
|
929
|
+
- `council` — a saved council or a built-in bench (`free`, `budget`, `frontier`) instead of `models`.
|
|
930
|
+
- `prompt` — the briefing every leg receives; `agent`, `thinking`, `timeout`, `summaryLength`, `gateway`, `includeContext`, `pack` and `tag` all mean what they do on `amicus_start`, applied to every leg (`timeout` is per-leg).
|
|
931
|
+
- `onComplete` — `mcp-notify`: send an MCP info notification carrying the terminal event doc when the wave finishes. Advisory and best-effort — `amicus_wait` stays the reliable completion mechanism, and exec commands are never accepted over MCP.
|
|
932
|
+
|
|
933
|
+
**`amicus_council_tally`** — deterministic tiers + street-cred from an assembled council record.
|
|
934
|
+
|
|
935
|
+
- `meta` — run metadata; `meta.models` lists every reviewed model, and `meta.intent` marks a task run.
|
|
936
|
+
- `findings` — the run-global findings, ids already `A1`/`B2`/`C3`-prefixed.
|
|
937
|
+
- `adjudications` — one row per (judge × finding): `judge`, `findingId`, `verdict`.
|
|
938
|
+
- `rankings` — each judge's preference order over the reviews; ties are a nested array.
|
|
939
|
+
- `runStats` — optional per-model run stats (status/duration/usage).
|
|
940
|
+
|
|
941
|
+
**`amicus_verdict`** — merge a tally record with Stage-4 decisions into `verdict.json`.
|
|
942
|
+
|
|
943
|
+
- `record` — a tally output record (from `amicus_council_tally`).
|
|
944
|
+
- `decisions` — per-finding Stage-4 decisions; defaults to `[]`.
|
|
945
|
+
- `overallVerdict` — the chair's terminal line, carried through from the engine-written `verdict.json`. It is the only copy; omit it when the chair was skipped, and never author one yourself.
|
|
946
|
+
- `seatLoss` / `degrades` — the engine-written critic-seating block and the "what was lost" list, carried through the same way. Omitted means absent, never fabricated.
|
|
947
|
+
- `render` — also return the markdown rendering of the decided verdict.
|
|
948
|
+
- `outDir` — where to refresh `report.html` when `render` is true; rejected if it escapes the project dir. Omit to write nothing.
|
|
949
|
+
|
|
950
|
+
**`amicus_council_run`** — the full headless council engine, no orchestrating agent required.
|
|
951
|
+
|
|
952
|
+
- `briefingFile` — path to the briefing (self-contained material + criteria). Councils always brief via file; the file is copied into the run dir.
|
|
953
|
+
- `models` — 2-10 bench seats, or `council` to name a saved council or a built-in bench instead.
|
|
954
|
+
- `chair` — the synthesizing model (default `deepseek`). Must NOT be a bench seat.
|
|
955
|
+
- `critic` — swap one bench seat to an adversarial brief. Must BE a bench seat; mutually exclusive with `lenses`.
|
|
956
|
+
- `lenses` — one expert lens per seat (count must equal seat count). Forces no-ledger; mutually exclusive with `critic`.
|
|
957
|
+
- `outDir` — the run directory. Default `<project>/council-<runId>/`.
|
|
958
|
+
- `maxCost` — whole-run USD ceiling, checked before each paid stage launch.
|
|
959
|
+
- `noCostGate` — disable the per-leg price gate for the WHOLE run, repairs and chair included. Independent of `maxCost`, which still caps the total. See [Cost gate](configuration.md#cost-gate).
|
|
960
|
+
- `timeoutMinutes` — per-leg timeout in minutes (fanout semantics). Default 15.
|
|
961
|
+
- `gateway` — routing preference, as on `amicus_start`.
|
|
962
|
+
- `debate` — add a Stage-2.5 rebuttal round before the chair synthesizes.
|
|
963
|
+
- `claudeReviewFile` — path to Claude's own review, included as a judged entry. Claude is reviewed and ranked like a seat, but never judges or chairs.
|
|
964
|
+
- `intent` — `task` marks a task-mode run (recorded on `run.json`/`verdict.json`, kept out of the reliability ledger): seats produce the deliverable and the chair closes with `ANSWER:` on a disjoint scale. `review` is the default and is never stored. See [docs/council.md § Task mode](./council.md#task-mode---intent-task).
|
|
965
|
+
- `ui` — auto-open the Council Workspace window for this run. Default: opens under Claude Code (local) when Electron and a display exist and `workspace.autoOpen` is not `false`.
|
|
966
|
+
- `onComplete`, `pack`, `tag` — as on `amicus_fanout`.
|
|
967
|
+
|
|
852
968
|
> Legacy `sidecar_*` tool names were removed entirely in v2.0.0 — the tool surface is `amicus_*` only, always. `AMICUS_LEGACY_ALIASES=1` (the v1.8.0 opt-in switch that used to restore the `sidecar_*` twins) is now a no-op: setting it on the MCP server entry changes nothing. See [docs/SHIMS.md](./SHIMS.md) for the removal record.
|
|
853
969
|
|
|
854
970
|
> The MCP server auto-detects whether it's running under Claude Code or Claude Desktop/Cowork (from the MCP `initialize` handshake) and passes the right `--client` value downstream — this drives context inclusion, MCP discovery, and session-dir resolution. If detection ever picks the wrong one, force it with `"env": {"AMICUS_MCP_CLIENT": "code-local"}` (or `code-web` / `cowork`) on the MCP server entry.
|
package/electron/ipc-setup.js
CHANGED
|
@@ -25,6 +25,10 @@ const { registerLocalProviderHandlers } = require('./ipc-setup-local');
|
|
|
25
25
|
* resolves through this local binding instead.
|
|
26
26
|
*/
|
|
27
27
|
function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ipcMain } = {}) {
|
|
28
|
+
// Offer-session catalog snapshots (V17/A4 + PR 199 B1/D2/A1) — the full
|
|
29
|
+
// lifetime contract lives in electron/offer-session.js.
|
|
30
|
+
const offerCatalogs = require('./offer-session').createOfferSessions();
|
|
31
|
+
|
|
28
32
|
ipcMain.handle('sidecar:validate-key', async (_event, provider, key) => {
|
|
29
33
|
try {
|
|
30
34
|
const { validateApiKey } = require('../src/utils/api-key-store');
|
|
@@ -62,6 +66,7 @@ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ip
|
|
|
62
66
|
const { buildProviderDefaultChoices } = require('../src/utils/provider-default-picker');
|
|
63
67
|
const catalog = await getCatalog();
|
|
64
68
|
result.providerDefault = buildProviderDefaultChoices(provider, { catalog });
|
|
69
|
+
offerCatalogs.set(_event, provider, catalog);
|
|
65
70
|
} catch (err) {
|
|
66
71
|
logger.error('save-key providerDefault error', { error: err.message });
|
|
67
72
|
result.providerDefault = null;
|
|
@@ -80,10 +85,38 @@ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ip
|
|
|
80
85
|
// Task 8: apply a per-provider default picker choice. Read-modify-write,
|
|
81
86
|
// no-clobber -- applyProviderDefault only ever writes aliases[vendor] and
|
|
82
87
|
// seeds config.default when absent (see provider-default-picker.js).
|
|
83
|
-
|
|
88
|
+
// Applies against the save-key offer's catalog snapshot (V17 / issue 195):
|
|
89
|
+
// applyProviderDefault uses directFormIfProven (model-canonicalization.js)
|
|
90
|
+
// to decide whether to strip an OpenRouter prefix off chosenId, and needs
|
|
91
|
+
// the catalog the offer was built from to do it. Fetching fresh only when
|
|
92
|
+
// no snapshot exists (no prior offer, or the offer session already ended
|
|
93
|
+
// via setup-done).
|
|
94
|
+
ipcMain.handle('sidecar:set-provider-default', async (_event, provider, chosenId) => {
|
|
95
|
+
// Reads WITHOUT consuming (PR 199 B1/D2 re-ruled after review F1): the
|
|
96
|
+
// user's pick is routinely the second-or-later apply for one offer
|
|
97
|
+
// (auto-apply on render, re-apply per radio change), and each must see
|
|
98
|
+
// the catalog the visible rows were built from. Staleness is bounded by
|
|
99
|
+
// the offer session instead: setup-done clears the map, a re-offer
|
|
100
|
+
// overwrites the entry.
|
|
101
|
+
let catalog = offerCatalogs.get(_event, provider);
|
|
102
|
+
if (!catalog) {
|
|
103
|
+
catalog = [];
|
|
104
|
+
try {
|
|
105
|
+
const { getCatalog } = require('../src/utils/model-catalog');
|
|
106
|
+
catalog = await getCatalog();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Best-effort only -- a fetch failure leaves `catalog` empty, which
|
|
109
|
+
// directFormIfProven (F1, council review of PR 198) reads as NO
|
|
110
|
+
// evidence, never as license to strip: chosenId is persisted exactly
|
|
111
|
+
// as given, not re-derived. Applying an already-made picker choice
|
|
112
|
+
// must never abort on a catalog hiccup, and must never fabricate an
|
|
113
|
+
// id on one either -- that was the exact bug issue 195 fixed.
|
|
114
|
+
logger.error('set-provider-default catalog fetch error', { error: err.message });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
84
117
|
try {
|
|
85
118
|
const { applyProviderDefault } = require('../src/utils/provider-default-picker');
|
|
86
|
-
return applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
|
|
119
|
+
return applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true, catalog });
|
|
87
120
|
} catch (err) {
|
|
88
121
|
logger.error('set-provider-default handler error', { error: err.message });
|
|
89
122
|
return { success: false, error: err.message };
|
|
@@ -119,6 +152,10 @@ function registerSetupHandlers(getMainWindow, { ipcMain = require('electron').ip
|
|
|
119
152
|
});
|
|
120
153
|
|
|
121
154
|
ipcMain.handle('sidecar:setup-done', (_event, defaultModel, keyCount) => {
|
|
155
|
+
// The offer session ends with the wizard: any apply after this fetches
|
|
156
|
+
// fresh evidence rather than reusing a closed offer's catalog (B1/D2).
|
|
157
|
+
// A1: only THIS sender's offer sessions end here — never another window's.
|
|
158
|
+
offerCatalogs.endSession(_event);
|
|
122
159
|
const { BrowserWindow } = require('electron');
|
|
123
160
|
const senderWindow = BrowserWindow.fromWebContents(_event.sender);
|
|
124
161
|
const mainWin = getMainWindow();
|
package/electron/main.js
CHANGED
|
@@ -327,11 +327,24 @@ function createAmicusWindow() {
|
|
|
327
327
|
async function createSetupWindow() {
|
|
328
328
|
// Lazy-load setup UI to avoid loading it for sidecar mode
|
|
329
329
|
const { buildSetupHTML } = require('./setup-ui');
|
|
330
|
-
const { resolveQuickPicks } = require('../src/utils/quick-picks');
|
|
330
|
+
const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
|
|
331
331
|
let quickPicks;
|
|
332
|
+
const shortlists = {};
|
|
332
333
|
try {
|
|
333
334
|
const catalog = await require('../src/utils/model-catalog').getCatalog();
|
|
334
335
|
quickPicks = resolveQuickPicks(catalog);
|
|
336
|
+
|
|
337
|
+
// issue 138: one vendor shortlist per family card, resolved server-side from
|
|
338
|
+
// the same catalog the quick picks came from (no extra IPC round-trip).
|
|
339
|
+
const { buildModelShortlist } = require('../src/utils/model-shortlist');
|
|
340
|
+
for (const p of quickPicks) {
|
|
341
|
+
try {
|
|
342
|
+
shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
|
|
343
|
+
catalog,
|
|
344
|
+
recommendedId: toStorableRoute(p),
|
|
345
|
+
});
|
|
346
|
+
} catch (_e) { /* a shortlist failure must never block the wizard */ }
|
|
347
|
+
}
|
|
335
348
|
} catch (_err) {
|
|
336
349
|
quickPicks = undefined; // buildSetupHTML falls back to pinned
|
|
337
350
|
}
|
|
@@ -348,7 +361,7 @@ async function createSetupWindow() {
|
|
|
348
361
|
}
|
|
349
362
|
});
|
|
350
363
|
|
|
351
|
-
const html = buildSetupHTML({ client: CLIENT, quickPicks });
|
|
364
|
+
const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists });
|
|
352
365
|
mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
|
353
366
|
mainWindow.webContents.on('page-title-updated', (e) => e.preventDefault());
|
|
354
367
|
|
|
@@ -499,6 +512,36 @@ registerSetupHandlers(() => mainWindow);
|
|
|
499
512
|
function createSettingsChildWindow() {
|
|
500
513
|
const { buildSetupHTML } = require('./setup-ui');
|
|
501
514
|
|
|
515
|
+
// issue 138: mirror createSetupWindow's catalog resolution so Step 2 shows
|
|
516
|
+
// live per-model options here too, instead of falling back to the pinned
|
|
517
|
+
// `[offline list]` badge. This window must stay synchronous (opening
|
|
518
|
+
// Settings must never trigger a network fetch), so read the on-disk cache
|
|
519
|
+
// directly with readCache() rather than the async fetch-and-refresh helper
|
|
520
|
+
// createSetupWindow awaits. A missing or corrupt cache reads back as null
|
|
521
|
+
// and degrades to the same pinned fallback buildSetupHTML already applies
|
|
522
|
+
// when no quickPicks are given.
|
|
523
|
+
const { resolveQuickPicks, toStorableRoute } = require('../src/utils/quick-picks');
|
|
524
|
+
const { readCache } = require('../src/utils/model-catalog');
|
|
525
|
+
let quickPicks;
|
|
526
|
+
const shortlists = {};
|
|
527
|
+
try {
|
|
528
|
+
const cacheDoc = readCache();
|
|
529
|
+
const catalog = cacheDoc ? cacheDoc.models : [];
|
|
530
|
+
quickPicks = resolveQuickPicks(catalog);
|
|
531
|
+
|
|
532
|
+
const { buildModelShortlist } = require('../src/utils/model-shortlist');
|
|
533
|
+
for (const p of quickPicks) {
|
|
534
|
+
try {
|
|
535
|
+
shortlists[p.alias] = buildModelShortlist(p.vendorPath, {
|
|
536
|
+
catalog,
|
|
537
|
+
recommendedId: toStorableRoute(p),
|
|
538
|
+
});
|
|
539
|
+
} catch (_e) { /* a shortlist failure must never block the wizard */ }
|
|
540
|
+
}
|
|
541
|
+
} catch (_err) {
|
|
542
|
+
quickPicks = undefined; // buildSetupHTML falls back to pinned
|
|
543
|
+
}
|
|
544
|
+
|
|
502
545
|
const settingsWin = new BrowserWindow({
|
|
503
546
|
width: 560, height: 680,
|
|
504
547
|
parent: mainWindow, modal: false,
|
|
@@ -512,7 +555,7 @@ function createSettingsChildWindow() {
|
|
|
512
555
|
}
|
|
513
556
|
});
|
|
514
557
|
|
|
515
|
-
const html = buildSetupHTML({ client: CLIENT });
|
|
558
|
+
const html = buildSetupHTML({ client: CLIENT, quickPicks, shortlists });
|
|
516
559
|
settingsWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
|
517
560
|
settingsWin.webContents.on('page-title-updated', (e) => e.preventDefault());
|
|
518
561
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offer-session catalog snapshots for the setup wizard's provider-default
|
|
3
|
+
* flow (extracted from ipc-setup.js at the 300-line gate, v4.9 / PR 199).
|
|
4
|
+
*
|
|
5
|
+
* V17 (council A4): each save-key offer snapshots the catalog it was built
|
|
6
|
+
* from, so set-provider-default applies against the SAME catalog the picker
|
|
7
|
+
* offered — a re-fetch there could return a different catalog and flip
|
|
8
|
+
* directFormIfProven's evidence (TOCTOU).
|
|
9
|
+
*
|
|
10
|
+
* Lifetime = the OFFER SESSION (PR 199 B1/D2, re-ruled after review F1):
|
|
11
|
+
* the wizard auto-applies on render and re-applies on every radio change,
|
|
12
|
+
* so EVERY apply while the offer is on screen must see the offer's own
|
|
13
|
+
* catalog — a one-shot delete-on-read handed every human pick a fresh
|
|
14
|
+
* fetch, which is the original A4 race. A re-offer overwrites the entry;
|
|
15
|
+
* setup-done ends the sender's sessions.
|
|
16
|
+
*
|
|
17
|
+
* Keyed per SENDER + provider (PR 199 round-2 council A1): two Settings
|
|
18
|
+
* windows are independent offer sessions — one window's offer, apply, or
|
|
19
|
+
* completion must never alter another's. A harness event with no sender id
|
|
20
|
+
* keys by provider alone (also the single-window behavior), and its
|
|
21
|
+
* endSession ends every session.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
function createOfferSessions() {
|
|
27
|
+
const snapshots = new Map();
|
|
28
|
+
const senderId = (event) => {
|
|
29
|
+
const sid = event && event.sender ? event.sender.id : undefined;
|
|
30
|
+
return (sid === undefined || sid === null) ? null : sid;
|
|
31
|
+
};
|
|
32
|
+
const key = (event, provider) => {
|
|
33
|
+
const sid = senderId(event);
|
|
34
|
+
return (sid !== null ? sid + ':' : '') + provider;
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
/** Arm (or re-arm) the sender's offer session for a provider. */
|
|
38
|
+
set(event, provider, catalog) { snapshots.set(key(event, provider), catalog); },
|
|
39
|
+
/** The sender's live offer catalog for a provider, or undefined. */
|
|
40
|
+
get(event, provider) { return snapshots.get(key(event, provider)); },
|
|
41
|
+
/** End every offer session belonging to this sender (setup-done). */
|
|
42
|
+
endSession(event) {
|
|
43
|
+
const sid = senderId(event);
|
|
44
|
+
for (const k of [...snapshots.keys()]) {
|
|
45
|
+
if (sid === null || k.startsWith(sid + ':')) { snapshots.delete(k); }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { createOfferSessions };
|
|
@@ -20,6 +20,26 @@ const PROVIDER_NAMES = {
|
|
|
20
20
|
deepseek: 'DeepSeek'
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* F5: HTML-escape a value for safe interpolation into BOTH an attribute
|
|
25
|
+
* value (double-quoted) and element text content. Catalog ids are
|
|
26
|
+
* data-controlled (this repo's own convention documents them as such --
|
|
27
|
+
* see renderSearchResults in electron/setup-ui.js, which uses createElement
|
|
28
|
+
* + textContent instead of a template string for this exact row source),
|
|
29
|
+
* so every id/name reaching a template string here must be escaped rather
|
|
30
|
+
* than trusted.
|
|
31
|
+
* @param {*} value
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function escapeAttr(value) {
|
|
35
|
+
return String(value === null || value === undefined ? '' : value)
|
|
36
|
+
.replace(/&/g, '&')
|
|
37
|
+
.replace(/</g, '<')
|
|
38
|
+
.replace(/>/g, '>')
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/'/g, ''');
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
/**
|
|
24
44
|
* Check if a model has at least one route with a configured key.
|
|
25
45
|
* When no keys are configured at all (empty configuredKeys), all models are available
|
|
@@ -63,15 +83,68 @@ function buildModelSearchHTML() {
|
|
|
63
83
|
</div>`;
|
|
64
84
|
}
|
|
65
85
|
|
|
86
|
+
/**
|
|
87
|
+
* issue 138: the family -> model second level for one card. Renders EVERY model
|
|
88
|
+
* in one <select> (scrollable and type-ahead searchable, so nothing is
|
|
89
|
+
* hidden), grouped "Suggested" / "All N models". Returns '' when no
|
|
90
|
+
* shortlist was supplied (or its `total` is 0).
|
|
91
|
+
*
|
|
92
|
+
* That '' is NOT dropped by the caller — buildModelStepHTML always splices
|
|
93
|
+
* it into the card template as its own line, so a card with no shortlist
|
|
94
|
+
* gains one whitespace-only line versus the pre-issue-138 HTML string. It is NOT
|
|
95
|
+
* byte-for-byte identical to that old output. It IS behaviorally identical:
|
|
96
|
+
* no <select> is emitted, and whitespace-only text nodes are inert once
|
|
97
|
+
* parsed as HTML, so the rendered card is unchanged.
|
|
98
|
+
*
|
|
99
|
+
* CONTROLLER RULING R1 (issue 138, 2026-08-24): every <option> carries
|
|
100
|
+
* data-or="<openrouterId>" (empty string when the row has no OpenRouter
|
|
101
|
+
* form), so a later task can read the user's chosen route without
|
|
102
|
+
* re-deriving a gateway prefix from the value id.
|
|
103
|
+
* @param {string} alias
|
|
104
|
+
* @param {{recommendedId:string, suggested:Array<object>, rest:Array<object>, total:number}} [shortlist]
|
|
105
|
+
* @returns {string} HTML fragment
|
|
106
|
+
*/
|
|
107
|
+
function buildModelPickHTML(alias, shortlist) {
|
|
108
|
+
if (!shortlist || !shortlist.total) { return ''; }
|
|
109
|
+
const opt = (r) => {
|
|
110
|
+
const price = r.pricePerMInput === null ? '' : ` · $${r.pricePerMInput.toFixed(2)}/M`;
|
|
111
|
+
const sel = r.isRecommended ? ' selected' : '';
|
|
112
|
+
return `<option value="${escapeAttr(r.id)}" data-or="${escapeAttr(r.openrouterId || '')}"${sel}>${escapeAttr(r.id)}${price}</option>`;
|
|
113
|
+
};
|
|
114
|
+
// Escaping-discipline consistency pass (council review, PR 196): alias
|
|
115
|
+
// is one of the five hardcoded FAMILIES names, not catalog data, so this
|
|
116
|
+
// is not closing a live vulnerability -- it matches the escapeAttr() use
|
|
117
|
+
// a few lines up for r.id/r.openrouterId, which ARE catalog-derived, so
|
|
118
|
+
// every attribute interpolation in this function follows the same rule.
|
|
119
|
+
let html = `<select class="model-pick" data-alias="${escapeAttr(alias)}">`;
|
|
120
|
+
html += `<optgroup label="Suggested">${shortlist.suggested.map(opt).join('')}</optgroup>`;
|
|
121
|
+
if (shortlist.rest.length > 0) {
|
|
122
|
+
// council review, PR 196 (F2): this optgroup holds ONLY `rest` -- label it
|
|
123
|
+
// by rest.length, not shortlist.total, or the label overstates what's in
|
|
124
|
+
// it (e.g. "All 14 models" over 6 rows when 8 are already under
|
|
125
|
+
// "Suggested" above). Singular/plural handled explicitly so a 9-row
|
|
126
|
+
// vendor with one leftover row doesn't read "1 models".
|
|
127
|
+
const restCount = shortlist.rest.length;
|
|
128
|
+
const restLabel = restCount === 1 ? '1 more model' : `${restCount} more models`;
|
|
129
|
+
html += `<optgroup label="${restLabel}">${shortlist.rest.map(opt).join('')}</optgroup>`;
|
|
130
|
+
}
|
|
131
|
+
return html + '</select>';
|
|
132
|
+
}
|
|
133
|
+
|
|
66
134
|
/**
|
|
67
135
|
* Build the HTML fragment for Step 2 (Model Selection).
|
|
68
136
|
* @param {Array<{alias:string, label:string, blurb:string, source:string, routes:Object<string,string>}>} choices
|
|
69
137
|
* Resolved rows from resolveQuickPicks(). Each row has separate label + blurb fields.
|
|
70
138
|
* @param {string} [selectedAlias] - Pre-selected alias; defaults to first available choice.
|
|
71
139
|
* @param {Object<string,boolean>} [configuredKeys] - Provider IDs the user has keys for.
|
|
140
|
+
* @param {Object<string,object>} [shortlists] - issue 138: per-alias vendor shortlist
|
|
141
|
+
* from buildModelShortlist(), used to render the model-level <select>.
|
|
142
|
+
* Defaults to {}; omitting it (or passing {}) is behaviorally identical to
|
|
143
|
+
* today's card (no <select> for that alias) but not byte-for-byte identical
|
|
144
|
+
* to the pre-issue-138 HTML string — see buildModelPickHTML's docstring.
|
|
72
145
|
* @returns {string} HTML fragment
|
|
73
146
|
*/
|
|
74
|
-
function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
147
|
+
function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}, shortlists = {}) {
|
|
75
148
|
// Determine availability for each model
|
|
76
149
|
const availability = choices.map(c => {
|
|
77
150
|
const providers = Object.keys(c.routes);
|
|
@@ -102,6 +175,20 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
|
102
175
|
|
|
103
176
|
// Resolved id for the write-preview (prefer bestProvider route)
|
|
104
177
|
const previewId = c.routes[bestProvider] || Object.values(c.routes)[0] || '';
|
|
178
|
+
// Escaping-discipline consistency pass (council review, PR 196; extended
|
|
179
|
+
// by a second pass, N-c): NOT closing a live vulnerability -- c.alias is
|
|
180
|
+
// one of the five hardcoded FAMILIES names and previewId is filtered
|
|
181
|
+
// through resolveQuickPicks' anchored idPattern regexes (or a hardcoded
|
|
182
|
+
// fallback), neither of which can carry a payload. This applies the
|
|
183
|
+
// same escapeAttr() used a few lines up (buildModelPickHTML, for
|
|
184
|
+
// genuinely catalog-derived r.id/r.openrouterId) to every c.alias /
|
|
185
|
+
// previewId interpolation in this card template -- attributes
|
|
186
|
+
// (data-alias, the radio value) and text content alike (.model-alias,
|
|
187
|
+
// .model-resolved, .write-preview-id, the write-preview <code>s) --
|
|
188
|
+
// so a reader of this template does not have to work out which
|
|
189
|
+
// interpolations are "safe" and which are escaped; the rule is uniform.
|
|
190
|
+
const escapedAlias = escapeAttr(c.alias);
|
|
191
|
+
const escapedPreviewId = escapeAttr(previewId);
|
|
105
192
|
|
|
106
193
|
// Offline badge for fallback rows
|
|
107
194
|
const badge = c.source === 'fallback'
|
|
@@ -114,23 +201,26 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
|
114
201
|
const pills = providers.map(p => {
|
|
115
202
|
const isActive = p === bestProvider;
|
|
116
203
|
const cls = isActive ? 'route-pill active' : 'route-pill';
|
|
117
|
-
return `<button class="${cls}" data-alias="${
|
|
204
|
+
return `<button class="${cls}" data-alias="${escapedAlias}" data-provider="${p}">${PROVIDER_NAMES[p]}</button>`;
|
|
118
205
|
}).join('');
|
|
119
206
|
const toggleDisplay = showToggle ? '' : ' style="display:none"';
|
|
120
207
|
const staticDisplay = showToggle ? ' style="display:none"' : '';
|
|
121
|
-
routeHtml = `<span class="route-toggle" data-alias="${
|
|
122
|
-
routeHtml += `<span class="route-static" data-alias="${
|
|
208
|
+
routeHtml = `<span class="route-toggle" data-alias="${escapedAlias}"${toggleDisplay}>${pills}</span>`;
|
|
209
|
+
routeHtml += `<span class="route-static" data-alias="${escapedAlias}"${staticDisplay}>via ${PROVIDER_NAMES[bestProvider]}</span>`;
|
|
123
210
|
} else {
|
|
124
211
|
routeHtml = `<span class="route-static">via ${PROVIDER_NAMES[bestProvider]}</span>`;
|
|
125
212
|
}
|
|
126
213
|
|
|
214
|
+
const modelPickHtml = buildModelPickHTML(c.alias, shortlists[c.alias]);
|
|
215
|
+
|
|
127
216
|
return `<label class="${cardClass}">
|
|
128
|
-
<input type="radio" name="default-model" value="${
|
|
129
|
-
<span class="model-alias">${
|
|
217
|
+
<input type="radio" name="default-model" value="${escapedAlias}" ${checked}${disabled}>
|
|
218
|
+
<span class="model-alias">${escapedAlias}</span>
|
|
130
219
|
<span class="model-label">${c.label} — ${c.blurb}</span>${badge}
|
|
131
|
-
<span class="model-resolved">${
|
|
220
|
+
<span class="model-resolved" data-alias="${escapedAlias}">${escapedPreviewId}</span>
|
|
132
221
|
${routeHtml}
|
|
133
|
-
|
|
222
|
+
${modelPickHtml}
|
|
223
|
+
<span class="write-preview" data-alias="${escapedAlias}">will set <code>${escapedAlias}</code> → <code class="write-preview-id">${escapedPreviewId}</code></span>
|
|
134
224
|
</label>`;
|
|
135
225
|
}).join('\n ');
|
|
136
226
|
|
|
@@ -145,4 +235,4 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
|
145
235
|
</div>`;
|
|
146
236
|
}
|
|
147
237
|
|
|
148
|
-
module.exports = { buildModelSearchHTML, buildModelStepHTML, PROVIDER_NAMES };
|
|
238
|
+
module.exports = { buildModelSearchHTML, buildModelStepHTML, buildModelPickHTML, PROVIDER_NAMES, escapeAttr };
|
|
@@ -192,6 +192,28 @@ function __rawWizardCSS() {
|
|
|
192
192
|
margin-left: auto; font-size: 11px; color: var(--text-faint); font-style: italic;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/* Model-level picker (Step 2 card) — issue 138. Mirrors .alias-model-select's
|
|
196
|
+
token-driven approach (background/border/radius/color/font/outline/cursor),
|
|
197
|
+
but this control lives inside a wrapping .model-card row alongside the
|
|
198
|
+
radio and route pills, not the Step-3 alias table's own linear row —
|
|
199
|
+
so it takes flex-basis:100% (its own line, below the route pills) rather
|
|
200
|
+
than alias-model-select's flex:1, and a plain --border (not --accent,
|
|
201
|
+
which the alias editor reserves for its active-edit state) so it reads
|
|
202
|
+
as a subordinate refinement, not the card's primary affordance. The
|
|
203
|
+
option/optgroup rules are load-bearing: Windows does not inherit a
|
|
204
|
+
<select>'s background/color into its <option>/<optgroup> children. */
|
|
205
|
+
.model-pick {
|
|
206
|
+
flex-basis: 100%; margin-top: 4px; padding: 3px 6px;
|
|
207
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
208
|
+
border-radius: var(--r-3); color: var(--text-muted); font-size: 11px;
|
|
209
|
+
font-family: var(--font-mono);
|
|
210
|
+
outline: none; cursor: pointer; max-width: 280px;
|
|
211
|
+
}
|
|
212
|
+
.model-pick:hover { border-color: var(--border-strong); }
|
|
213
|
+
.model-pick:focus { border-color: var(--accent); }
|
|
214
|
+
.model-pick option { background: var(--surface); color: var(--text); }
|
|
215
|
+
.model-pick optgroup { color: var(--text-muted); font-style: normal; }
|
|
216
|
+
|
|
195
217
|
/* Routing example */
|
|
196
218
|
.routing-example {
|
|
197
219
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-8);
|