amicus 4.3.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +32 -0
- package/README.md +4 -3
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +27 -0
- package/electron/preload-workspace.js +40 -0
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +101 -0
- package/electron/workspace-ui/md-lite.js +119 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +212 -0
- package/electron/workspace-ui/workspace-panels.js +226 -0
- package/electron/workspace-ui/workspace-render.js +271 -0
- package/electron/workspace-ui/workspace-verbs.js +247 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +14 -0
- package/schemas/progress.schema.json +14 -1
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +32 -5
- package/src/cli-handlers-watch.js +37 -10
- package/src/council/briefings.js +35 -2
- package/src/council/run-budget.js +224 -0
- package/src/council/run-launch.js +44 -6
- package/src/council/run-stages.js +17 -3
- package/src/council/run.js +12 -11
- package/src/headless.js +347 -14
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +183 -0
- package/src/observe/live-doc.js +21 -3
- package/src/observe/watch-render.js +19 -0
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +198 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg.js +23 -1
- package/src/sidecar/fanout.js +4 -11
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +21 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/path-fence.js +82 -0
- package/src/utils/pricing.js +98 -9
- package/src/workspace/artifact-guard.js +187 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +95 -0
- package/src/workspace/live-normalize.js +156 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +223 -0
- package/src/workspace/run-scan.js +148 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "4.
|
|
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,6 +3,38 @@
|
|
|
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
|
+
## [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
|
+
|
|
6
38
|
## [4.3.0] - 2026-07-24
|
|
7
39
|
|
|
8
40
|
### Added
|
package/README.md
CHANGED
|
@@ -45,13 +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
|
|
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
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.
|
|
55
56
|
|
|
56
57
|
Claude is the orchestrator. The council and chat skills run *on top of* the engine; you talk to Claude, and Claude drives Amicus.
|
|
57
58
|
|
|
@@ -314,7 +315,7 @@ amicus update
|
|
|
314
315
|
| `amicus continue` | Start a new session building on a previous one. |
|
|
315
316
|
| `amicus read` | Output a session's summary / conversation / metadata. |
|
|
316
317
|
| `amicus status <id>` | One-shot status for a session or fan-out wave (human or `--json`; `--wave <id>` alternative spelling). |
|
|
317
|
-
| `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. |
|
|
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`). |
|
|
318
319
|
| `amicus models` | List, search, refresh the catalog, or audit aliases. |
|
|
319
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). |
|
|
320
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). |
|
|
@@ -350,7 +351,7 @@ $ amicus status demo123 --json
|
|
|
350
351
|
"taskId": "demo123",
|
|
351
352
|
"status": "complete",
|
|
352
353
|
"elapsed": "5m 0s",
|
|
353
|
-
"version": "4.
|
|
354
|
+
"version": "4.4.0",
|
|
354
355
|
"model": "google/gemini-2.5-flash",
|
|
355
356
|
"phase": "terminal"
|
|
356
357
|
}
|
|
@@ -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 };
|