amicus 4.2.1 → 4.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.
Files changed (82) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +46 -1
  3. package/README.md +8 -4
  4. package/bin/amicus.js +5 -0
  5. package/electron/ipc-workspace.js +283 -0
  6. package/electron/main.js +27 -0
  7. package/electron/preload-workspace.js +40 -0
  8. package/electron/workspace-shell.js +85 -0
  9. package/electron/workspace-ui/index.html +111 -0
  10. package/electron/workspace-ui/live-model.js +101 -0
  11. package/electron/workspace-ui/md-lite.js +119 -0
  12. package/electron/workspace-ui/workspace-app.js +240 -0
  13. package/electron/workspace-ui/workspace-matrix.js +212 -0
  14. package/electron/workspace-ui/workspace-panels.js +226 -0
  15. package/electron/workspace-ui/workspace-render.js +271 -0
  16. package/electron/workspace-ui/workspace-verbs.js +247 -0
  17. package/electron/workspace-ui/workspace.css +172 -0
  18. package/package.json +1 -1
  19. package/schemas/council-run-live.schema.json +57 -0
  20. package/schemas/council-run.schema.json +14 -0
  21. package/schemas/event.schema.json +15 -0
  22. package/schemas/progress.schema.json +37 -0
  23. package/schemas/run-live.schema.json +15 -0
  24. package/schemas/spend.schema.json +26 -1
  25. package/schemas/wave-live.schema.json +15 -0
  26. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  27. package/src/cli-handlers-council-run.js +86 -8
  28. package/src/cli-handlers-run.js +26 -0
  29. package/src/cli-handlers-spend.js +94 -32
  30. package/src/cli-handlers-watch.js +116 -0
  31. package/src/cli.js +58 -1
  32. package/src/council/briefings.js +35 -2
  33. package/src/council/run-budget.js +224 -0
  34. package/src/council/run-chair.js +10 -2
  35. package/src/council/run-debate.js +5 -1
  36. package/src/council/run-launch.js +58 -7
  37. package/src/council/run-stages.js +30 -3
  38. package/src/council/run.js +44 -15
  39. package/src/headless.js +356 -15
  40. package/src/mcp-council-awareness.js +98 -3
  41. package/src/mcp-council-run.js +28 -4
  42. package/src/mcp-notify.js +54 -0
  43. package/src/mcp-server.js +51 -1
  44. package/src/mcp-spend.js +125 -0
  45. package/src/mcp-tools.js +39 -0
  46. package/src/mcp-wait.js +28 -2
  47. package/src/observe/council-legs.js +183 -0
  48. package/src/observe/events.js +156 -0
  49. package/src/observe/follow.js +26 -0
  50. package/src/observe/live-doc.js +56 -0
  51. package/src/observe/on-complete.js +117 -0
  52. package/src/observe/watch-render.js +168 -0
  53. package/src/opencode-client.js +15 -3
  54. package/src/sidecar/child-sessions.js +198 -0
  55. package/src/sidecar/continue.js +32 -0
  56. package/src/sidecar/conversation-mirror.js +111 -37
  57. package/src/sidecar/fallback-chains.js +65 -0
  58. package/src/sidecar/fanout-budget.js +71 -0
  59. package/src/sidecar/fanout-leg-fallback.js +189 -0
  60. package/src/sidecar/fanout-leg.js +81 -27
  61. package/src/sidecar/fanout-retry.js +208 -0
  62. package/src/sidecar/fanout-validate.js +42 -4
  63. package/src/sidecar/fanout.js +54 -41
  64. package/src/sidecar/progress.js +5 -0
  65. package/src/sidecar/resume.js +12 -0
  66. package/src/sidecar/start.js +13 -1
  67. package/src/sidecar/tool-part.js +196 -0
  68. package/src/sidecar/workspace-window.js +62 -0
  69. package/src/spend-query.js +119 -0
  70. package/src/utils/env-num.js +42 -0
  71. package/src/utils/error-classify.js +31 -0
  72. package/src/utils/model-tiers.js +1 -1
  73. package/src/utils/path-fence.js +82 -0
  74. package/src/utils/pricing.js +98 -9
  75. package/src/utils/spend-ledger.js +24 -1
  76. package/src/workspace/artifact-guard.js +187 -0
  77. package/src/workspace/blind-mode.js +32 -0
  78. package/src/workspace/fold-format.js +95 -0
  79. package/src/workspace/live-normalize.js +156 -0
  80. package/src/workspace/matrix-model.js +94 -0
  81. package/src/workspace/run-detail.js +223 -0
  82. package/src/workspace/run-scan.js +148 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "4.2.1",
3
+ "version": "4.4.0",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -3,7 +3,52 @@
3
3
  All notable changes to Amicus are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow semver.
5
5
 
6
- ## [Unreleased]
6
+ ## [4.4.0] - 2026-07-26
7
+
8
+ ### Added
9
+
10
+ - **Council Workspace (GUI)** — third Electron mode `council-workspace`, opened via `amicus watch <councilRunId> --ui` (bare `--ui` opens the project run list). Renders live and historical council runs: stage rail, live per-seat status/tokens/cost (v4.3 data layer, 1.5s/5s poll depending on window focus/visibility), verbatim anonymized Stage-2 packet, tier-colored adjudication matrix with basis counts/thin/override badges, dissent drill-in with prose highlight, chair verdict + street-cred + Stage-4 decisions, cost-by-seat with `--max-cost` gauge. Blind-mode toggle (labels vs models; ON while live, OFF once terminal — a reading aid against anchoring bias, not a security control: the label map is plaintext in `run.json`). Two verbs: confirm-gated Abort (delegates to the engine's own council-aware abort path) and nonced Fold (chair verdict to the launching terminal; no model call). Fully sandboxed first-party page (CSP with no network directive at all, `contextIsolation`, textContent-only rendering of model prose, enforced by a static source scan); read-only against run directories **apart from the Abort verb, which checkpoints the run through the engine's own in-process abort path** — not a direct write from the workspace code itself. `--ui` is interactive-only (`--json` is rejected, not silently ignored).
11
+ - `CdpClient.workspace(port)` e2e factory (`file://` target, port 9225) + a fixture-driven workspace CDP suite.
12
+
13
+ ### Fixed
14
+
15
+ - **Zero-cost reporting no longer lies about spend.** A leg whose captured token totals were *all zero* used to be priced as `0 × catalog price` and labelled `estimated $0.0000` — an authoritative "this seat was free" for work that had genuinely been billed. Diagnosed against four real paid council runs plus OpenCode's own session database: `council-wsgate02` spent **$0.9859 against a `--max-cost` ceiling of $0.75 (131%)** while Amicus believed $0.3720 and never emitted `COST_EXCEEDED`. Four separate defects, all fixed here:
16
+ - `resolveLegCost` now gates the estimate on **observed tokens**, not on the mere existence of a price, so a zero-token leg resolves to `{amount: null, source: 'unknown'}`. The v4.2 free-local-provider `$0` tier is unaffected — a local seat still reports real token counts, so it keeps resolving to `estimated ~$0.0000`.
17
+ - The headless poll loop's fast-path exits (trailing fold marker, SDK `idle`) could break *before* OpenCode stamps `info.tokens`/`info.cost` — measured losing by 155 ms and 29 ms on real paid legs. A bounded, best-effort **post-loop usage re-poll** (≤3 reads, ~1.2 s worst case, usage capture only — never re-mirrors text) now closes that window.
18
+ - `progress.json`'s usage snapshot was only ever written on `receiving` flushes, i.e. always before finalization — 31 of 35 real legs ended with an all-zero snapshot while `metadata.json` held thousands of tokens, and that snapshot is what the live GUI reads. A **terminal `complete` progress record** now carries the settled usage, and the reader prefers `metadata.json` for any terminal leg.
19
+ - `amicus spend` and the spend ledger no longer coerce a null cost into a measured-looking `$0.0000`: a model whose rows are all unpriced renders `?`, and `unpricedRows` is reported on `total`, `byModel`, every `group`, and `wasted`.
20
+ - **A leg is no longer declared `complete` while its OpenCode session is still working and billing.** Measured on a real paid run: `council-wsgate02`'s `wsgate02-s1-3` was declared complete on **166 characters** of reasoning preamble, 129 s before its `task` tool call finished, and its session then billed $0.14279 of further parent spend plus a $0.47105 child session — 166 characters were adjudicated as a finished peer review. Root cause was a shape drift, not a logic slip: the mirror modelled a tool call as an Anthropic-style `tool_use` part cleared by a matching `tool_result`, and **OpenCode emits neither** (36 `tool_use` records and 0 `tool_result` records across 35 recorded legs; 5,129 persisted parts resolve to six type names, none of them `tool_result`). So `pendingToolCalls` never cleared for any real leg, tool names never reached `conversation.jsonl`, and the `Task`-subagent log was permanently empty. Tool-call liveness is now keyed on the SDK's real `state.status` vocabulary (`pending`/`running`/`completed`/`error`; terminal = `completed`|`error`), and the completion gates that lack an explicit done-signal defer while a call is still executing. The wait is **bounded** by `AMICUS_TOOL_SETTLE_GRACE_MS` (default 300 s, `0` disables): on exceeding it the leg **completes anyway** — never fails — carrying `toolSettleTimedOut` on its result, its `metadata.json` and its terminal `progress.json` record, plus an error-level log line. A tool part whose status cannot be observed at all is deliberately *not* treated as live, so an unknown shape can never hang a finished leg.
21
+ - **`costExact: true` no longer claims a total is complete when it is not.** `council-wsgate01` reported `costExact: true` while **$0.0215 short** of OpenCode's ledger. Reconciled leg-by-leg: all 7 legs were `source: 'reported'` with real tokens, and **100% of the gap was one unattributed `explore` child session** ($0.021460, parent `wsgate01-s1-2`) — not rounding, not partial usage, not float drift. The predicate was wrong: `costExact` was computed as `unknownLegs === 0`, which asks "did every leg report tokens" — a statement about each leg's *own* session, not about whether the total is the whole bill. A leg that spawns a subagent now carries `subtreeUnknown` on its usage block, `sumWaveUsage` reports `subtreeUnknownLegs`, and `costExact` requires **both** every leg observed *and* no unattributed subtree. Surfaced on `run.json`, the `Notice:` line, the human summary, the workspace total and the `--max-cost` gauge (which goes indeterminate). Subtree-unknown spend still does not trip the ceiling — fail loud, not closed.
22
+ - **`--max-cost` is now threaded into the council pre-flight estimate.** `src/council/run-launch.js` never passed `maxCost` to the transport, so `src/sidecar/fanout.js` fell back to a `cfg.maxCost` key that does not exist and the `budget.js` soft ceiling was inert for every council run — the post-hoc check in `run.js` was the only ceiling, and it can only refuse *after* the money is spent. Each wave is now measured against the **remaining** allowance (ceiling − known spend − outstanding reservations).
23
+ - **The council pre-flight ceiling is now concurrency-safe.** Stage 1 launches its seat wave and its critic wave together under one `Promise.all`, and each launcher read the remaining allowance *before either wave's legs had been recorded* — so both saw the full, unreduced ceiling and both could pass a gate that only one of them fit under. A read is not a claim. The transport now takes an optional `reserveBudget(estimate)` seam (`src/sidecar/fanout-budget.js`, extracted from `runFanout` §1b) which the council answers with a **synchronous** read-and-claim against the allowance no sibling wave has taken — synchronicity is the guarantee, since the event loop cannot interleave two callers inside it. A fixed quota split was rejected as strictly more refusing than the ceiling requires. **When a wave is refused the run continues with a partial bench** — it never rolls back launched waves and never aborts (fail loud, not closed) — but the refusal is announced on stderr, recorded on `run.json` as `budgetRefusals[]`, and degrades the run's exit code to `2`. Stage 1's existing quorum gate still refuses to call a bench of fewer than two reviews a council.
24
+ - **The best-effort usage-settle re-poll can no longer discard a finished leg.** Its `try/catch` covered only the network read; the snapshot inspection that followed it (`mirrorUsageOnly`, `allAssistantUsagePresent`) ran outside the boundary, so a throw there escaped `runHeadless` and destroyed a leg whose answer had already been captured and paid for — the most expensive possible outcome for a path whose whole job is an optional usage top-up. The boundary now covers the entire loop body; a failure stops settling, keeps every dollar already mirrored, and leaves the completion verdict untouched.
25
+ - **`AMICUS_USAGE_SETTLE_POLLS=0` (and friends) now actually disable the feature.** All four v4.4 settle knobs parsed their environment override as `Number(process.env.X) || DEFAULT`, which silently rewrites an explicit `0` — the documented "off" value — back into the default. `AMICUS_USAGE_SETTLE_POLLS`, `AMICUS_USAGE_SETTLE_INTERVAL_MS`, `AMICUS_USAGE_SETTLE_CALL_TIMEOUT_MS` and `AMICUS_TOOL_SETTLE_GRACE_MS` now go through `src/utils/env-num.js`, which honors an explicit numeric value including `0` and falls back only for unset / blank / non-finite. Older knobs (`AMICUS_POLL_INTERVAL_MS`, `AMICUS_STABLE_*_POLLS`, `AMICUS_TOOL_CALL_STALL_MS`, …) deliberately keep the old form: `0` is not a documented escape hatch for any of them and honoring it would busy-loop a poller or disable a stall guard.
26
+ - **Subagent (child-session) spend is now attributed to the leg that spawned it.** A leg that calls the `task` tool spawns a *child* OpenCode session; OpenCode bills it separately, does **not** roll it into the parent session's cost, and amicus never enumerated it — so it was invisible to every total the product prints. Measured across the four recorded paid runs: **$0.492506** ($0.021460 in `wsgate01`, $0.471046 in `wsgate02`). `wsgate01` was the honest limit case — all 7 legs `source: 'reported'`, `unpricedLegs: 0`, `costExact: true`, and the run still 7.1% short, with 100% of the gap in one `explore` child session. `runHeadless` now walks each leg's child sessions at finalization (bounded, cycle-proof, directory-scoped) and the measured spend rolls into the run total, reported separately as `cost.subtreeCost` / `cost.subtreeSessions`. Replayed against the OpenCode oracle, `wsgate01` reconciles **exactly**. A child's price comes from OpenCode's own billing and is never estimated from a catalog — the SDK's session record carries no model id, so an estimate would be a guess. What the walk cannot account for still reports as `subtreeUnknown`, never as zero; conversely, a subtree that WAS fully walked now clears that flag, which the previous `task`-name proxy could never do. A failed walk with no evidence of a subagent at all flags nothing, so an OpenCode build without the `children` endpoint does not mark every leg of every run inexact.
27
+ - **A Stage-1 repair re-prompt now carries the review it is repairing.** When a review's trailing findings JSON failed validation the engine launched a repair solo — a *fresh* session with no memory of the review turn — and handed it the validation **errors without the review those errors were about**. Three of the five paid councils burned a seat on it: `wsgate02`'s `qwen` and `wsgate04`'s `glm` both refused, twice each ("I don't have a previous review to correct"; "the previous review's content was excluded by the caller… I will not fabricate findings"), so a 4-model bench silently adjudicated on 3 while still paying for the fourth's tokens; `costgate01`'s `grok` complied instead, by **inventing a self-referential finding about its own empty output**, which entered `tally.json`, the street-cred rankings and the chair synthesis as `C1` and reached a human's decision. The prompt now embeds, verbatim and uncapped, the text that actually failed — the original review on the first attempt, the previous repair's output on the second, so the errors and the artifact they describe are always the same thing. When there genuinely is no prior text the prompt **says so** and instructs the model to emit an empty `findings` array rather than leaving it to guess.
28
+
29
+ ### Changed
30
+
31
+ - **Unknown cost fails LOUD, not CLOSED.** A leg whose cost cannot be determined does **not** halt a run and does **not** by itself trip `--max-cost` — the ceiling still trips on known spend only. Instead the uncertainty is made impossible to miss: `run.json`'s `usage` block gains `unknownLegs` + `costExact`, the council run emits a `Notice:` naming the count and stating that real spend is higher, the human summary appends `+ N leg(s) unknown — real spend is at least this much`, `amicus spend` adds an explicit unpriced-rows line, and the workspace's budget gauge switches to an indeterminate (hatched) band with a `≥` readout rather than claiming a percentage it cannot know. Nothing converts uncertainty into a fabricated number in either direction.
32
+ - **`amicus watch <councilRunId>` now prints per-seat rows in the terminal**, not just the stage checklist — each seat's model, status, message count, tokens, cost and stall state, refreshed on the same poll as the stage rail. This is a **behavior change to an existing command**: a plain terminal `amicus watch` on a council run shows materially more than it did on 4.3.0, with no new flag. The Council Workspace GUI and the terminal renderer now read the same per-leg data.
33
+
34
+ ### Security
35
+
36
+ - **A council pointer file can no longer redirect reads — or writes — outside the project.** A `council-<runId>.json` pointer's `{runId, runDir}` JSON is validated only for truthiness (`src/council/run-state.js`), so a tampered or stale pointer could name any `runDir` on disk. The v4.4 Council Workspace already fenced all four of its pointer-consuming reads against the run dir's realpath; the older CLI/MCP surface behind `amicus_status` / `amicus_abort` / `amicus_list` / `amicus watch` did not, and two of its call sites are worse than a read leak — both crash detection and abort `checkpoint()` **into** `ptr.runDir`, making an unfenced pointer a write primitive at an attacker-chosen path. All of them now resolve and check containment **before touching the filesystem at all**, sharing the one fence implementation (`src/utils/path-fence.js`). A fenced-out pointer resolves to the existing "not a council run" outcome — the same `Session <id> not found in project <cwd>` error `amicus_status`/`amicus_abort` already return for an absent pointer, a skipped row in `amicus_list`, and `kind: 'unknown'` (→ `BAD_SESSION`) for `watch` — so no new error shape, and nothing is read or written from the escaping directory. Nothing legitimate is refused: a real `runDir` is always nested inside the project, enforced at creation time.
37
+
38
+ ## [4.3.0] - 2026-07-24
39
+
40
+ ### Added
41
+
42
+ - **Observability data layer.** Three file surfaces every consumer polls, no push/IPC/`fs.watch` anywhere: the existing durable snapshots (`metadata.json`/`progress.json`/`wave.json`/council `run.json`, all additively extended), a new append-only `events.jsonl` milestone stream per wave dir / council-run dir, and the composed live doc (the `amicus_status` rollup, stamped `view:'live'` with per-leg read-time `usage`).
43
+ - **`amicus watch <id>`** — live-render any fan-out wave, council run, or session from any terminal, reading only the data layer above (no attach): an in-place refresh table on a TTY, milestone lines (`--plain` / non-TTY), or NDJSON (`--json`). `--interval` controls the poll rate (default 2s, floor 0.5s); exit code maps the run's terminal state (`complete`→0, `partial`→2, else 1). `--ui` registers the flag for the v4.4 Council Workspace GUI (rejects `--json`) — the GUI itself is not shipped in this release.
44
+ - **`--follow` on `fanout` and `council run`** — stream a run's own milestone events to stderr as they happen; stdout's `--json`/human contracts stay byte-identical. On `council run`, `--follow` covers the run's own lifecycle and each stage's boundaries, not the per-leg events inside a stage's internal fan-out sub-wave.
45
+ - **`--on-complete` hook.** CLI: runs a user-authored shell command once a wave/council run reaches a terminal state, with the payload carried via 8 environment variables (`AMICUS_TASK_ID`, `AMICUS_TYPE`, `AMICUS_STATUS`, `AMICUS_EXIT_CODE`, `AMICUS_RESULT_FILE`, `AMICUS_EVENTS_FILE`, `AMICUS_COST`, `AMICUS_PROJECT`) — ids/paths only, never model-generated text; exit-isolated from the run (a non-zero exit or a 60s timeout is a warning only). MCP: only `onComplete: "mcp-notify"` is accepted, a best-effort advisory notification — `exec` is never exposed over MCP.
46
+ - **Failed-leg resilience.** `fanout --retry-failed <waveId>` relaunches only a wave's terminal, non-complete legs as a new linked wave (byte-identical retry from each leg's saved context; `--models` filters which legs retry; the original `wave.json` is never touched). `--fallback` / `--no-fallback` opt into per-leg cheaper-model substitution, off by default, triggered only by a classified rate-limit/overload failure (never timeout or auth) and always recorded loudly (a `leg-fallback` event, an `attempts[]` array, a `fallback` block on the final doc).
47
+ - **Spend visibility & attribution.** `continue`/`resume`/council rows are now recorded in the spend ledger, not just `start`/`fanout` legs, and every row carries attribution (`op`/`status`/`waveId`/`councilRunId`/`councilName`/`project`/`gateway`, plus fallback/retry linkage). `amicus spend` grows a full query surface — `--wave`/`--council`/`--project`/`--model`/`--op`/`--failed`/`--group-by <model|wave|council|project|op|day>`/`--rows` — plus a `wasted` rollup (both `--failed` and `wasted` deliberately exclude rows with no recorded status at all, so a pre-v4.3 ledger row is never counted as a failure that was never actually recorded). A new read-only `amicus_spend` MCP tool (16th tool) mirrors the same flags for MCP-only hosts.
48
+
49
+ ### Notes
50
+
51
+ - All additive: no schema-breaking changes to v4.0 artifacts. `SPEND_LEDGER_SCHEMA_VERSION` stays `1`; both JSONL ledgers (`spend-ledger.jsonl`, `council-ledger.jsonl`) remain internal, non-envelope files, not published docs. A wave dir / council-run dir now also contains `events.jsonl`.
7
52
 
8
53
  ## [4.2.1] - 2026-07-23
9
54
 
package/README.md CHANGED
@@ -45,12 +45,14 @@ Hand Claude a plan, a design, a diff, an architecture decision, a manuscript —
45
45
 
46
46
  ## What is Amicus
47
47
 
48
- One install delivers four things that work together:
48
+ One install delivers six things that work together:
49
49
 
50
50
  - **The `second-opinion` LLM Council skill.** Structured multi-model review: independent reviews → anonymized peer cross-review → a non-Claude chair verdict → tiered accept/deny decisions. This is the hero.
51
51
  - **The `sidecar` chat skill.** Ad-hoc fork/work/fold — spin up one other model in a real window (or headless), work alongside it, fold the summary back.
52
52
  - **The `amicus` CLI (with an `am` alias) and an MCP server.** The engine underneath both skills: launches sessions, shares context, runs parallel waves, and exposes the same surface to Claude as MCP tools.
53
53
  - **A self-updating model catalog.** Aliases and validation resolve against a live catalog fetched from provider APIs (cached locally), so model names stay current without a hard-coded table.
54
+ - **Observability.** `amicus watch <id>` renders any live or finished run (fan-out or council) from any terminal; `--follow` streams milestones as they happen; `--on-complete` fires a hook when a run lands; `--retry-failed` plus opt-in cheaper-model fallbacks recover dead legs without relaunching the whole wave; `amicus spend` answers "what did this cost, and where" with per-run attribution.
55
+ - **Council Workspace.** `amicus watch <runId> --ui`: a window that shows a council *thinking* — live seats, the anonymized judge packet, the adjudication matrix, dissent drill-in, chair verdict, and cost-by-seat — for both live and historical runs.
54
56
 
55
57
  Claude is the orchestrator. The council and chat skills run *on top of* the engine; you talk to Claude, and Claude drives Amicus.
56
58
 
@@ -313,9 +315,10 @@ amicus update
313
315
  | `amicus continue` | Start a new session building on a previous one. |
314
316
  | `amicus read` | Output a session's summary / conversation / metadata. |
315
317
  | `amicus status <id>` | One-shot status for a session or fan-out wave (human or `--json`; `--wave <id>` alternative spelling). |
318
+ | `amicus watch <id>` | Live-render a fan-out wave, council run, or session from any terminal until it finishes — in-place table on a TTY, `--plain` milestone lines, or `--json` NDJSON; exit code maps the terminal state. `--ui` opens the [Council Workspace](./docs/council.md#council-workspace-gui) window instead (interactive-only, no `--json`). |
316
319
  | `amicus models` | List, search, refresh the catalog, or audit aliases. |
317
320
  | `amicus doctor` | Diagnose your setup — keys, default model, catalog, aliases, OpenCode binary, Electron, skills, MCP registration, OpenRouter credit (`--json`; `--fix` self-heals what it can). |
318
- | `amicus spend` | Cross-run cost rollup from the spend ledger — total + per-model spend, tokens, and source mix, most-expensive first (`--since 7d` windows it; `--json` for a versioned doc; shows remaining OpenRouter credit when a key is configured). |
321
+ | `amicus spend` | Cross-run cost rollup from the spend ledger, with per-run attribution — total + per-model spend, tokens, and source mix, most-expensive first (`--wave`/`--council`/`--project`/`--model`/`--op`/`--failed` filter it, `--group-by` buckets it, `--since 7d` windows it; `--json` for a versioned doc; shows remaining OpenRouter credit when a key is configured). |
319
322
  | `amicus key` | Manage API keys non-interactively: `amicus key <provider> <key>` saves after live validation; `--remove`; bare `amicus key` lists providers. |
320
323
  | `amicus provider` | Add/list/test/remove local, OpenAI-compatible providers (LM Studio, Ollama, vLLM) — configured with `--preset` or `--url`, at **$0** marginal cost (`--json` on every subcommand). |
321
324
  | `amicus council` | Council math: `tally <input.json>` (deterministic tiers + ledger append), `stats` (reviewer reliability), `report <verdict.json> [--md\|--html]`, `validate <file>` (findings-block check, exit 0/2/1), `verdict <tally.json> [--decisions <d.json>] [-o <out.json>]` (build + write verdict.json). Presets: `save <name> --models a,b,c`, `list [--json]`, `show <name> [--json]` — see [The Council](#the-council) for the built-in `free`/`budget`/`frontier` benches. |
@@ -348,7 +351,7 @@ $ amicus status demo123 --json
348
351
  "taskId": "demo123",
349
352
  "status": "complete",
350
353
  "elapsed": "5m 0s",
351
- "version": "4.2.1",
354
+ "version": "4.4.0",
352
355
  "model": "google/gemini-2.5-flash",
353
356
  "phase": "terminal"
354
357
  }
@@ -382,7 +385,7 @@ Full details, the API-key/prefix table, and the migration notice are in **[docs/
382
385
 
383
386
  ## MCP integration
384
387
 
385
- The MCP server is auto-registered on install (Claude Code and Claude Desktop / Cowork). It exposes fifteen tools:
388
+ The MCP server is auto-registered on install (Claude Code and Claude Desktop / Cowork). It exposes sixteen tools:
386
389
 
387
390
  | Tool | What it does |
388
391
  |------|--------------|
@@ -401,6 +404,7 @@ The MCP server is auto-registered on install (Claude Code and Claude Desktop / C
401
404
  | `amicus_council_stats` | Reviewer-reliability stats from past council runs. |
402
405
  | `amicus_verdict` | Build the final council verdict from a tally + decisions; writes `<outDir>/report.html` when `render:true` and `outDir` are given. |
403
406
  | `amicus_council_run` | Run the full headless council engine (Stage-1 reviews → anonymized cross-review → tally → chair verdict); returns `{ runId, runDir }`, async. |
407
+ | `amicus_spend` | Read-only cross-run cost rollup from the spend ledger (mirrors the CLI `amicus spend` flags). |
404
408
 
405
409
  The async pattern is **start → status → read** — `amicus_start`/`amicus_fanout` return immediately, then you poll `amicus_status` and call `amicus_read`; `amicus_wait` collapses that poll loop into one blocking call.
406
410
 
package/bin/amicus.js CHANGED
@@ -127,6 +127,11 @@ async function main() {
127
127
  exitCode = await handleSpend(args);
128
128
  break;
129
129
  }
130
+ case 'watch': {
131
+ const { handleWatch } = require('../src/cli-handlers-watch');
132
+ exitCode = await handleWatch(args);
133
+ break;
134
+ }
130
135
  case 'provider': {
131
136
  const { handleProvider } = require('../src/cli-handlers-provider');
132
137
  exitCode = await handleProvider(args);
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Council Workspace IPC (v4.4 §4.5) — all seven workspace: channels.
3
+ *
4
+ * ipc-setup.js registration pattern + DI for tests. Every handler validates
5
+ * the sender against the workspace window via isPrivilegedSender (M9
6
+ * belt-and-suspenders, even though this window has one webContents). All
7
+ * reads are main-process-side; the renderer never sees a filesystem path
8
+ * except for display. Read-only against runDir (spec §6.2): fold writes to
9
+ * OUR stdout; abort delegates to the engine's own council-aware path.
10
+ *
11
+ * The in-process v4.3/v4.0 seam: require('../src/mcp-server').handlers —
12
+ * requiring mcp-server does NOT start the server (cli-handlers-status.js:3).
13
+ */
14
+ 'use strict';
15
+
16
+ const path = require('path');
17
+ const { pathToFileURL } = require('url');
18
+ const { logger } = require('../src/utils/logger');
19
+ const { isPrivilegedSender } = require('./ipc-guard');
20
+ const { isRealpathContained } = require('../src/workspace/artifact-guard');
21
+
22
+ function defaultDeps() {
23
+ return {
24
+ scanCouncilRuns: (...a) => require('../src/workspace/run-scan').scanCouncilRuns(...a),
25
+ getRunDetail: (...a) => require('../src/workspace/run-detail').getRunDetail(...a),
26
+ readPointer: (...a) => require('../src/workspace/run-scan').readPointer(...a),
27
+ readRunArtifact: (...a) => require('../src/workspace/artifact-guard').readRunArtifact(...a),
28
+ buildFoldText: (...a) => require('../src/workspace/fold-format').buildFoldText(...a),
29
+ normalizeLive: (...a) => require('../src/workspace/live-normalize').normalizeLive(...a),
30
+ handlers: () => require('../src/mcp-server').handlers,
31
+ // Node passes an Error to this callback when the chunk fails to flush
32
+ // (e.g. a dead parent pipe / closed stdout) — reject on it rather than
33
+ // resolving unconditionally, or a failed write reports {ok:true} to the
34
+ // renderer and permanently latches gate.hasCompleted() (code review fix).
35
+ stdoutWrite: (text) => new Promise((resolve, reject) => {
36
+ process.stdout.write(text, (err) => (err ? reject(err) : resolve()));
37
+ }),
38
+ openExternal: (url) => require('electron').shell.openExternal(url),
39
+ existsSync: (p) => require('fs').existsSync(p),
40
+ realpathSync: (p) => require('fs').realpathSync(p),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Two-flag fold latch (fold.js folded/completed pattern WITHOUT the
46
+ * close-guard fallback-destroy machinery — a workspace fold does no async
47
+ * model work, so that failure surface does not apply; spec §7).
48
+ *
49
+ * ⚠️ COUNCIL REVIEW R2 (A3): `completed` used to be a single unkeyed boolean —
50
+ * one successful fold latched the gate for the ENTIRE life of the window, for
51
+ * every run, not just the one that was actually folded. The renderer supports
52
+ * navigating to a different run within one open window (openRun with a
53
+ * dynamic runId; a bare `--ui` launch opens the whole project run list, spec
54
+ * §4.3/§4.4), so: open run A, Fold (succeeds) -> switch to run B, Fold ->
55
+ * the old code returned {ok:true, already:true} WITHOUT ever writing
56
+ * anything for B. Silent wrong-result, not cosmetic: the UI reads "Folded ✓"
57
+ * for a run that was never written to the launching terminal.
58
+ *
59
+ * Completion is now keyed per runId (a Set of runIds that have been
60
+ * successfully folded), so each DISTINCT run gets its own honest
61
+ * begin()/hasCompleted() pair. `writing` (in flight) stays a single GLOBAL
62
+ * flag on purpose: at most one fold write can be in progress at any instant
63
+ * regardless of which run it is for (there is exactly one stdout stream to
64
+ * write to), so a second concurrent call — for the SAME run or a different
65
+ * one — still correctly gets 'fold-in-flight' rather than interleaving two
66
+ * writes.
67
+ *
68
+ * Why per-run, not "reset the whole gate when the active run changes" or
69
+ * "keep one global one-shot latch and just report it more honestly": the
70
+ * plan's spec §7 line ("the fold nonce is per-window-launch... one fold
71
+ * write per launch... a second fold of the SAME run legitimately returns
72
+ * already:true") was written before multi-run navigation existed and reads
73
+ * naturally as "don't spam-refold what you already folded" rather than "the
74
+ * whole window may only ever fold one run, period." Concretely, allowing one
75
+ * successful fold per DISTINCT run within a single launch is safe: the
76
+ * workspace's fold-write path (src/sidecar/workspace-window.js) just relays
77
+ * accumulated stdout live and returns the exit code once the window closes —
78
+ * it does no first/last-marker disambiguation the way the OLDER headless
79
+ * model-session flow's findTrailingFoldMarker does (src/headless.js) — and
80
+ * every fold block is self-identifying (`Session: <runId>`,
81
+ * src/workspace/fold-format.js), so two distinct runs folded into the same
82
+ * launch's stdout are unambiguous to whatever reads it. A global
83
+ * one-shot-per-window latch would instead be a real product regression: it
84
+ * would make "browse several runs and fold the ones you care about" — the
85
+ * whole point of the window NOT auto-closing after a fold — impossible
86
+ * without reopening the workspace for every run after the first.
87
+ */
88
+ function createFoldGate() {
89
+ let writing = false;
90
+ let writingRunId = null;
91
+ const completedRuns = new Set();
92
+ let pendingClose = false;
93
+ return {
94
+ /** @returns {boolean} true when a write may start for runId */
95
+ begin(runId) {
96
+ if (writing || completedRuns.has(runId)) { return false; }
97
+ writing = true;
98
+ writingRunId = runId;
99
+ return true;
100
+ },
101
+ /** @returns {boolean} true when a close was blocked during the write */
102
+ settle({ ok }) {
103
+ writing = false;
104
+ if (ok) { completedRuns.add(writingRunId); }
105
+ writingRunId = null;
106
+ const p = pendingClose;
107
+ pendingClose = false;
108
+ return p;
109
+ },
110
+ isWriting() { return writing; },
111
+ hasCompleted(runId) { return completedRuns.has(runId); },
112
+ noteBlockedClose() { pendingClose = true; },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * @param {() => object|null} getWindow returns the workspace BrowserWindow
118
+ * @param {{project: string, nonce: string, ipc?: object, gate?: object, deps?: object}} ctx
119
+ * @returns {{gate: object}}
120
+ */
121
+ function registerWorkspaceHandlers(getWindow, ctx) {
122
+ const { project, nonce } = ctx;
123
+ const deps = { ...defaultDeps(), ...(ctx.deps || {}) };
124
+ const ipc = ctx.ipc || require('electron').ipcMain;
125
+ const gate = ctx.gate || createFoldGate();
126
+ const fromWorkspace = (event) => isPrivilegedSender(event, getWindow);
127
+ // ⚠️ CODE REVIEW (task-9 post-approval): settle()'s pendingClose signal used to be
128
+ // read only on the success path below, so a close blocked while a fold was writing
129
+ // silently dropped on the two failure paths (settle({ok:false}) at the detail-error
130
+ // early return and in the outer catch) — the window stayed open with no further
131
+ // close handler poised to retry until the user clicked X again. All three settle()
132
+ // call sites must honor a pending close identically.
133
+ const closeIfPending = (shouldClose) => {
134
+ if (!shouldClose) { return; }
135
+ const win = getWindow();
136
+ if (win && !win.isDestroyed()) { win.close(); }
137
+ };
138
+
139
+ ipc.handle('workspace:list-runs', (event) => {
140
+ if (!fromWorkspace(event)) { return []; }
141
+ try { return deps.scanCouncilRuns(project); }
142
+ catch (err) { logger.error('workspace list-runs failed', { error: err.message }); return []; }
143
+ });
144
+
145
+ ipc.handle('workspace:get-run', (event, runId) => {
146
+ if (!fromWorkspace(event)) { return { error: 'unauthorized' }; }
147
+ try { return deps.getRunDetail(project, String(runId)); }
148
+ catch (err) { return { runId: String(runId), error: err.message }; }
149
+ });
150
+
151
+ ipc.handle('workspace:get-live', async (event, runId) => {
152
+ if (!fromWorkspace(event)) { return { ok: false, error: 'unauthorized' }; }
153
+ try {
154
+ const result = await deps.handlers().amicus_status({ taskId: String(runId) }, project);
155
+ const text = result && result.content && result.content[0] ? result.content[0].text : '';
156
+ if (result && result.isError) { return { ok: false, error: String(text).slice(0, 500) }; }
157
+ let doc;
158
+ try { doc = JSON.parse(text); } catch { return { ok: false, error: 'unparseable status doc' }; }
159
+ return deps.normalizeLive(doc);
160
+ } catch (err) {
161
+ return { ok: false, error: err.message };
162
+ }
163
+ });
164
+
165
+ ipc.handle('workspace:read-artifact', (event, runId, name) => {
166
+ if (!fromWorkspace(event)) { return { error: 'unauthorized' }; }
167
+ try { return deps.readRunArtifact(project, String(runId), String(name)); }
168
+ catch (err) { return { error: err.message }; }
169
+ });
170
+
171
+ ipc.handle('workspace:abort-run', async (event, runId) => {
172
+ if (!fromWorkspace(event)) { return { ok: false, error: 'unauthorized' }; }
173
+ try {
174
+ const result = await deps.handlers().amicus_abort({ taskId: String(runId) }, project);
175
+ const text = result && result.content && result.content[0] ? result.content[0].text : '';
176
+ return { ok: !(result && result.isError), detail: String(text).slice(0, 2000) };
177
+ } catch (err) {
178
+ return { ok: false, error: err.message };
179
+ }
180
+ });
181
+
182
+ ipc.handle('workspace:fold', async (event, runId) => {
183
+ if (!fromWorkspace(event)) { return { ok: false, error: 'unauthorized' }; }
184
+ const runKey = String(runId);
185
+ // Gate keyed on runKey (council review R2, A3 — see createFoldGate's
186
+ // docblock): completing run A must not latch a DIFFERENT run B.
187
+ if (gate.hasCompleted(runKey)) { return { ok: true, already: true }; }
188
+ if (!gate.begin(runKey)) { return { ok: false, error: 'fold-in-flight' }; }
189
+ try {
190
+ const detail = deps.getRunDetail(project, runKey);
191
+ if (detail.error || !detail.run || detail.run.parseError) {
192
+ closeIfPending(gate.settle({ ok: false }));
193
+ return { ok: false, error: detail.error || 'run.json unavailable' };
194
+ }
195
+ const chairRead = deps.readRunArtifact(project, runKey, 'chair-output.md');
196
+ // Distinguish a security fence firing (e.g. the realpath-escape check)
197
+ // from a benign absence (not written yet) — both fall through to the
198
+ // same safe fallback body below, but only one of them is worth an
199
+ // operator's attention (code review fix).
200
+ if (chairRead && chairRead.error) {
201
+ logger.warn('Workspace fold: chair-output.md unavailable', { runId: runKey, reason: chairRead.error });
202
+ }
203
+ const chairText = chairRead && chairRead.text ? chairRead.text : null;
204
+ const text = deps.buildFoldText({
205
+ nonce, project, run: detail.run, tally: detail.tally, verdict: detail.verdict, chairText,
206
+ });
207
+ await deps.stdoutWrite(text + '\n');
208
+ logger.info('Workspace fold completed', { runId: runKey });
209
+ closeIfPending(gate.settle({ ok: true }));
210
+ return { ok: true };
211
+ } catch (err) {
212
+ closeIfPending(gate.settle({ ok: false }));
213
+ logger.error('Workspace fold failed', { error: err.message });
214
+ return { ok: false, error: err.message };
215
+ }
216
+ });
217
+
218
+ ipc.handle('workspace:open-report', (event, runId) => {
219
+ if (!fromWorkspace(event)) { return { ok: false, error: 'unauthorized' }; }
220
+ // ⚠️ DE-ROT (F59): the SHIPPED readPointer (src/council/run-state.js:133-139,
221
+ // which Task 2's dedupe makes this deps entry resolve to) returns **null** for a
222
+ // missing/corrupt pointer — it never returns {error}. A bare `ptr.error` throws a
223
+ // TypeError straight out of the handler, and this was the only one of the seven
224
+ // with no try/catch. Guard for null AND wrap, like the other six.
225
+ try {
226
+ const ptr = deps.readPointer(project, String(runId));
227
+ if (!ptr || ptr.error) { return { ok: false, error: (ptr && ptr.error) || 'run pointer not found' }; }
228
+
229
+ // Containment fence FIRST (council review C1, MAJOR, unanimous; ROUND 4 ORDERING
230
+ // FIX, third live paid council, blocker-adjacent — this handler is the direct
231
+ // analogue of the same bug fixed in src/workspace/artifact-guard.js's
232
+ // readRunArtifact): this handler was the only filesystem-reaching workspace:
233
+ // channel with no containment check, and the only one that hands a disk-derived
234
+ // path to a shell verb (openExternal) — ptr.runDir is charset-blind and never
235
+ // validated beyond truthiness (src/council/run-state.js:133-139). The
236
+ // existsSync(report) probe used to run BEFORE this fence: read-only and
237
+ // Boolean, so it never leaked file content, but it let a tampered/stale pointer
238
+ // use file-EXISTENCE as an out-of-project oracle ahead of any containment check
239
+ // — the same "resolve+fence before touching the filesystem at all" invariant
240
+ // every other channel in this file now honours. Resolve+fence runDir first;
241
+ // only then probe/read anything derived from it.
242
+ let realProject, realRunDir;
243
+ try {
244
+ realProject = deps.realpathSync(project);
245
+ realRunDir = deps.realpathSync(ptr.runDir);
246
+ } catch {
247
+ return { ok: false, error: 'run directory unreadable' };
248
+ }
249
+ if (!isRealpathContained(realProject, realRunDir)) {
250
+ return { ok: false, error: 'run directory escapes project' };
251
+ }
252
+
253
+ const report = path.join(ptr.runDir, 'report.html');
254
+ if (!deps.existsSync(report)) { return { ok: false, error: 'report.html not written' }; }
255
+
256
+ // Inner fence (independent of the one above): report.html must resolve inside
257
+ // runDir too — a symlinked report.html can't escape the run dir either. Fail
258
+ // closed on any realpath error (permission, dangling symlink) rather than let
259
+ // it fall through to openExternal. Generic message (round 4): a readFileSync/
260
+ // realpathSync failure's err.message embeds the full resolved path it tried to
261
+ // open — interpolating it here would hand the renderer an internal filesystem
262
+ // path via the IPC response.
263
+ let realReport;
264
+ try {
265
+ realReport = deps.realpathSync(report);
266
+ } catch {
267
+ return { ok: false, error: 'report path unreadable' };
268
+ }
269
+ if (!isRealpathContained(realRunDir, realReport)) {
270
+ return { ok: false, error: 'report escapes run directory' };
271
+ }
272
+
273
+ deps.openExternal(pathToFileURL(realReport).href);
274
+ return { ok: true };
275
+ } catch (err) {
276
+ return { ok: false, error: err.message };
277
+ }
278
+ });
279
+
280
+ return { gate };
281
+ }
282
+
283
+ module.exports = { registerWorkspaceHandlers, createFoldGate };
package/electron/main.js CHANGED
@@ -79,6 +79,10 @@ const WINDOW_POSITION = process.env.AMICUS_WINDOW_POSITION || 'right';
79
79
  // marker. That's safe here because the GUI fold write is exit-code driven, not
80
80
  // marker-detected, so an un-advertised fallback nonce can't be exploited.
81
81
  const FOLD_NONCE = process.env.AMICUS_FOLD_NONCE;
82
+ // v4.4 Council Workspace mode (AMICUS_MODE=council-workspace). Set by
83
+ // src/sidecar/workspace-window.js; PROJECT defaults to cwd for direct launches.
84
+ const PROJECT = process.env.AMICUS_PROJECT || process.cwd();
85
+ const RUN_ID = process.env.AMICUS_RUN_ID || '';
82
86
 
83
87
  const OPENCODE_URL = `http://localhost:${OPENCODE_PORT}`;
84
88
 
@@ -510,6 +514,27 @@ function createSettingsChildWindow() {
510
514
  settingsWin.webContents.on('page-title-updated', (e) => e.preventDefault());
511
515
  }
512
516
 
517
+ // ============================================================================
518
+ // Council Workspace (v4.4) — third mode; window body lives in workspace-shell.js
519
+ // ============================================================================
520
+
521
+ function createCouncilWorkspace() {
522
+ const { createWorkspaceWindow } = require('./workspace-shell');
523
+ const { registerWorkspaceHandlers, createFoldGate } = require('./ipc-workspace');
524
+ const { generateFoldNonce } = require('../src/utils/fold-marker');
525
+ const gate = createFoldGate();
526
+ registerWorkspaceHandlers(() => mainWindow, {
527
+ project: PROJECT,
528
+ nonce: FOLD_NONCE || generateFoldNonce(),
529
+ gate,
530
+ });
531
+ mainWindow = createWorkspaceWindow({ runId: RUN_ID, gate, headless: !!process.env.AMICUS_HEADLESS_TEST });
532
+ mainWindow.on('closed', () => {
533
+ mainWindow = null;
534
+ app.quit();
535
+ });
536
+ }
537
+
513
538
  // ============================================================================
514
539
  // App Lifecycle
515
540
  // ============================================================================
@@ -523,6 +548,8 @@ app.whenReady().then(() => {
523
548
 
524
549
  if (MODE === 'setup') {
525
550
  createSetupWindow().catch((err) => { logger.error('createSetupWindow failed', err); });
551
+ } else if (MODE === 'council-workspace') {
552
+ createCouncilWorkspace();
526
553
  } else {
527
554
  createAmicusWindow();
528
555
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Council Workspace Preload — minimal typed IPC bridge (v4.4 §4.2/§4.5).
3
+ *
4
+ * The workspace page loads via loadFile (a real file:// origin), so
5
+ * contextBridge works under FULL sandbox — no data:-URL workaround needed
6
+ * (docs/electron-testing.md Known Limitations; the setup wizard only works
7
+ * because it runs sandbox:false, a posture this page must not inherit: it
8
+ * renders untrusted model prose, the H9 surface).
9
+ *
10
+ * Exposes exactly one function. No openExternal bridge, no listeners.
11
+ * Unknown channel → throw (preload-setup.js pattern).
12
+ *
13
+ * ⚠️ DE-ROT (F30): this header used to name the banned electron module (the
14
+ * one preload-setup.js:9 imports for openExternal). Step 1 asserts
15
+ * expect(SRC).not.toContain(...) against the RAW file source — comments
16
+ * included — so that word here made Task 7 fail its own test. Never let the
17
+ * literal token appear anywhere in this file. Step 4's "PASS (3 tests)" is
18
+ * correct only with the reworded line above.
19
+ */
20
+ const { contextBridge, ipcRenderer } = require('electron');
21
+
22
+ const ALLOWED_CHANNELS = [
23
+ 'workspace:list-runs',
24
+ 'workspace:get-run',
25
+ 'workspace:get-live',
26
+ 'workspace:read-artifact',
27
+ 'workspace:abort-run',
28
+ 'workspace:fold',
29
+ 'workspace:open-report',
30
+ ];
31
+
32
+ contextBridge.exposeInMainWorld('amicusWorkspace', {
33
+ /** Invoke an allowlisted workspace IPC channel. */
34
+ invoke: (channel, ...args) => {
35
+ if (!ALLOWED_CHANNELS.includes(channel)) {
36
+ throw new Error(`IPC channel not allowed: ${channel}`);
37
+ }
38
+ return ipcRenderer.invoke(channel, ...args);
39
+ },
40
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Council Workspace window (v4.4 §4.1/§4.2) — the third Electron mode's
3
+ * BrowserWindow. One window, no WebContentsView split: the workspace is a
4
+ * single first-party page. Extracted from main.js (grandfathered size) so
5
+ * the posture is source-testable.
6
+ *
7
+ * H9 posture: this page renders untrusted model prose, so it is the MOST
8
+ * locked-down page in the app — full sandbox, minimal preload, CSP in the
9
+ * page itself, and it never navigates or opens windows.
10
+ *
11
+ * Close semantics (spec §7 resolved Q3): NO auto-fold on close — closing a
12
+ * viewer discards nothing (all artifacts are on disk). The only guard is the
13
+ * in-flight fold-write latch supplied by ipc-workspace's gate.
14
+ */
15
+ 'use strict';
16
+
17
+ const path = require('path');
18
+ const { BrowserWindow } = require('electron');
19
+ const { TOKENS } = require('../src/design/tokens');
20
+ // ⚠️ DE-ROT (F31): shipped guard for exactly this show:false hazard — reuse, don't
21
+ // re-invent (electron/load-failsafe.js:27, paired with show:false at main.js:210-232).
22
+ const { attachLoadFailsafe } = require('./load-failsafe');
23
+ const { logger } = require('../src/utils/logger');
24
+
25
+ const ICON_PATH = path.join(__dirname, 'assets', 'icon.png');
26
+
27
+ /**
28
+ * @param {{runId?: string, gate: {isWriting: () => boolean, noteBlockedClose: () => void}, headless?: boolean}} opts
29
+ * @returns {BrowserWindow}
30
+ */
31
+ function createWorkspaceWindow({ runId, gate, headless }) {
32
+ const win = new BrowserWindow({
33
+ width: 1100, height: 800, minWidth: 860, minHeight: 600,
34
+ show: false,
35
+ frame: true, backgroundColor: TOKENS.bg,
36
+ title: 'Amicus Council Workspace',
37
+ icon: ICON_PATH,
38
+ webPreferences: {
39
+ preload: path.join(__dirname, 'preload-workspace.js'),
40
+ contextIsolation: true, nodeIntegration: false, sandbox: true,
41
+ },
42
+ });
43
+
44
+ win.webContents.on('will-navigate', (event) => { event.preventDefault(); });
45
+ win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
46
+ win.webContents.on('page-title-updated', (e) => e.preventDefault());
47
+
48
+ // ⚠️ DE-ROT (F31): attach BEFORE loadFile. onFail shows the window anyway (so the
49
+ // user sees an empty-but-present shell instead of nothing) and logs the reason;
50
+ // default timeout is 15s (load-failsafe.js:19). No buildLoadErrorHTML here — the
51
+ // workspace page is first-party and CSP-locked, so just surface + log.
52
+ const failsafe = attachLoadFailsafe({
53
+ webContents: win.webContents,
54
+ onFail: ({ reason, errorCode, errorDescription, validatedURL }) => {
55
+ logger.error('Council Workspace page failed to load', {
56
+ reason, errorCode, errorDescription, validatedURL, runId: runId || ''
57
+ });
58
+ if (!headless && !win.isDestroyed()) { win.show(); }
59
+ },
60
+ });
61
+
62
+ // ⚠️ CODE REVIEW: loadFile() rejects on load failure (and, until Task 11 lands
63
+ // workspace-ui/index.html, on EVERY launch). The failsafe above already surfaces
64
+ // and logs the failure, so swallow the rejection itself rather than leaving an
65
+ // unhandled rejection in the main process (precedent: main.js's own
66
+ // .loadURL(...).catch(() => {})).
67
+ win.loadFile(path.join(__dirname, 'workspace-ui', 'index.html'), { query: { runId: runId || '' } })
68
+ .catch(() => {});
69
+
70
+ win.once('ready-to-show', () => {
71
+ failsafe.cancel(); // disarm on the success path
72
+ if (!headless) { win.show(); }
73
+ });
74
+
75
+ win.on('close', (event) => {
76
+ if (gate && gate.isWriting()) {
77
+ event.preventDefault();
78
+ gate.noteBlockedClose();
79
+ }
80
+ });
81
+
82
+ return win;
83
+ }
84
+
85
+ module.exports = { createWorkspaceWindow };