amicus 4.3.0 → 4.4.1
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 +64 -0
- package/README.md +6 -3
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +31 -1
- package/electron/preload-workspace.js +40 -0
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +112 -0
- package/electron/workspace-ui/md-lite.js +163 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +249 -0
- package/electron/workspace-ui/workspace-panels.js +237 -0
- package/electron/workspace-ui/workspace-render.js +277 -0
- package/electron/workspace-ui/workspace-verbs.js +293 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/progress.schema.json +26 -1
- package/schemas/spend.schema.json +52 -4
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +50 -5
- package/src/cli-handlers-watch.js +48 -10
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +59 -3
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +277 -0
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +73 -7
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +148 -113
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +52 -53
- package/src/council/tally.js +10 -0
- package/src/headless.js +519 -17
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +240 -0
- package/src/observe/live-doc.js +39 -4
- package/src/observe/watch-render.js +23 -1
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +197 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +29 -1
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +65 -81
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +33 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +120 -0
- package/src/utils/pricing.js +114 -9
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +208 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +124 -0
- package/src/workspace/live-normalize.js +169 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +229 -0
- package/src/workspace/run-scan.js +148 -0
package/electron/fold.js
CHANGED
|
@@ -66,7 +66,7 @@ function createFoldHandler(state) {
|
|
|
66
66
|
`Session: ${state.sessionId || state.taskId}`,
|
|
67
67
|
`Client: ${state.client}`,
|
|
68
68
|
`CWD: ${state.cwd}`,
|
|
69
|
-
|
|
69
|
+
'Mode: interactive',
|
|
70
70
|
'---',
|
|
71
71
|
summary || 'Session ended without summary.'
|
|
72
72
|
].join('\n');
|
|
@@ -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
|
|
|
@@ -138,7 +142,10 @@ function createAmicusWindow() {
|
|
|
138
142
|
let updateInfo = null;
|
|
139
143
|
const updateInfoRaw = process.env.AMICUS_UPDATE_INFO;
|
|
140
144
|
if (updateInfoRaw) {
|
|
141
|
-
try { updateInfo = JSON.parse(updateInfoRaw); } catch (_) {
|
|
145
|
+
try { updateInfo = JSON.parse(updateInfoRaw); } catch (_) {
|
|
146
|
+
// A malformed AMICUS_UPDATE_INFO is not worth failing launch over:
|
|
147
|
+
// updateInfo stays null and the direct check below takes over.
|
|
148
|
+
}
|
|
142
149
|
}
|
|
143
150
|
if (!updateInfo) {
|
|
144
151
|
const { getUpdateInfo, initUpdateCheck } = require('../src/utils/updater');
|
|
@@ -510,6 +517,27 @@ function createSettingsChildWindow() {
|
|
|
510
517
|
settingsWin.webContents.on('page-title-updated', (e) => e.preventDefault());
|
|
511
518
|
}
|
|
512
519
|
|
|
520
|
+
// ============================================================================
|
|
521
|
+
// Council Workspace (v4.4) — third mode; window body lives in workspace-shell.js
|
|
522
|
+
// ============================================================================
|
|
523
|
+
|
|
524
|
+
function createCouncilWorkspace() {
|
|
525
|
+
const { createWorkspaceWindow } = require('./workspace-shell');
|
|
526
|
+
const { registerWorkspaceHandlers, createFoldGate } = require('./ipc-workspace');
|
|
527
|
+
const { generateFoldNonce } = require('../src/utils/fold-marker');
|
|
528
|
+
const gate = createFoldGate();
|
|
529
|
+
registerWorkspaceHandlers(() => mainWindow, {
|
|
530
|
+
project: PROJECT,
|
|
531
|
+
nonce: FOLD_NONCE || generateFoldNonce(),
|
|
532
|
+
gate,
|
|
533
|
+
});
|
|
534
|
+
mainWindow = createWorkspaceWindow({ runId: RUN_ID, gate, headless: !!process.env.AMICUS_HEADLESS_TEST });
|
|
535
|
+
mainWindow.on('closed', () => {
|
|
536
|
+
mainWindow = null;
|
|
537
|
+
app.quit();
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
513
541
|
// ============================================================================
|
|
514
542
|
// App Lifecycle
|
|
515
543
|
// ============================================================================
|
|
@@ -523,6 +551,8 @@ app.whenReady().then(() => {
|
|
|
523
551
|
|
|
524
552
|
if (MODE === 'setup') {
|
|
525
553
|
createSetupWindow().catch((err) => { logger.error('createSetupWindow failed', err); });
|
|
554
|
+
} else if (MODE === 'council-workspace') {
|
|
555
|
+
createCouncilWorkspace();
|
|
526
556
|
} else {
|
|
527
557
|
createAmicusWindow();
|
|
528
558
|
}
|
|
@@ -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
|
+
});
|
|
@@ -22,7 +22,7 @@ const ALIAS_GROUPS = [
|
|
|
22
22
|
* @returns {string} HTML fragment with search, groups, rows, and add button
|
|
23
23
|
*/
|
|
24
24
|
function buildAliasEditorHTML(aliases) {
|
|
25
|
-
const searchInput =
|
|
25
|
+
const searchInput = '<input type="text" id="alias-search" class="alias-search" placeholder="Search aliases..." autocomplete="off" spellcheck="false">';
|
|
26
26
|
|
|
27
27
|
const groups = ALIAS_GROUPS.map(group => {
|
|
28
28
|
const rows = group.keys
|
|
@@ -31,10 +31,10 @@ function buildAliasEditorHTML(aliases) {
|
|
|
31
31
|
const model = aliases[key];
|
|
32
32
|
return `<div class="alias-row" data-alias="${key}">` +
|
|
33
33
|
`<span class="alias-name">${key}</span>` +
|
|
34
|
-
|
|
34
|
+
'<span class="alias-arrow">\u2192</span>' +
|
|
35
35
|
`<span class="alias-model">${model}</span>` +
|
|
36
36
|
`<button class="alias-delete" data-alias="${key}">\u00d7</button>` +
|
|
37
|
-
|
|
37
|
+
'</div>';
|
|
38
38
|
}).join('\n ');
|
|
39
39
|
|
|
40
40
|
const count = group.keys.filter(key => aliases[key] !== undefined).length;
|
|
@@ -51,9 +51,9 @@ function buildAliasEditorHTML(aliases) {
|
|
|
51
51
|
const exampleModel = aliases[exampleAlias] || 'openrouter/google/gemini-3.1-flash-lite-preview';
|
|
52
52
|
|
|
53
53
|
// SVG icons for the example box
|
|
54
|
-
const terminalIcon =
|
|
55
|
-
const arrowIcon =
|
|
56
|
-
const modelIcon =
|
|
54
|
+
const terminalIcon = '<svg class="alias-icon-accent" width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" class="alias-icon-faint-path" stroke-width="1.5" stroke-linecap="round"/></svg>';
|
|
55
|
+
const arrowIcon = '<svg class="alias-icon-accent" width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
56
|
+
const modelIcon = '<svg class="alias-icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke-width="1.5"/><path d="M8 8v3" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke-width="1" stroke-linecap="round"/></svg>';
|
|
57
57
|
|
|
58
58
|
const exampleBox = `<div class="routing-example">
|
|
59
59
|
<div class="example-label">How it works</div>
|
|
@@ -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 };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta http-equiv="Content-Security-Policy"
|
|
6
|
+
content="default-src 'none'; script-src file:; style-src file: 'unsafe-inline'; font-src file:; img-src file: data:">
|
|
7
|
+
<title>Amicus Council Workspace</title>
|
|
8
|
+
<link rel="stylesheet" href="../../src/design/tokens.css">
|
|
9
|
+
<link rel="stylesheet" href="./workspace.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app">
|
|
13
|
+
<aside id="rail" aria-label="Council runs">
|
|
14
|
+
<header class="rail-head">
|
|
15
|
+
<span class="rail-title">Council runs</span>
|
|
16
|
+
<span id="rail-project" class="rail-project mono"></span>
|
|
17
|
+
</header>
|
|
18
|
+
<ul id="run-list" tabindex="0" aria-label="Run list"></ul>
|
|
19
|
+
</aside>
|
|
20
|
+
|
|
21
|
+
<main id="detail">
|
|
22
|
+
<div id="empty-state" class="empty-state">Select a council run</div>
|
|
23
|
+
|
|
24
|
+
<div id="run-view" hidden>
|
|
25
|
+
<header id="run-header">
|
|
26
|
+
<div class="run-head-row">
|
|
27
|
+
<h1 id="run-title" class="mono"></h1>
|
|
28
|
+
<div id="run-chips" class="chips"></div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="run-head-row">
|
|
31
|
+
<div id="cost-gauge" class="gauge" role="meter" aria-label="Run cost vs ceiling">
|
|
32
|
+
<div id="cost-gauge-fill" class="gauge-fill"></div>
|
|
33
|
+
<span id="cost-gauge-text" class="gauge-text mono"></span>
|
|
34
|
+
</div>
|
|
35
|
+
<label class="blind-label">
|
|
36
|
+
<input type="checkbox" id="blind-toggle"> Blind mode
|
|
37
|
+
</label>
|
|
38
|
+
<button id="abort-btn" class="btn danger" hidden>Abort run…</button>
|
|
39
|
+
</div>
|
|
40
|
+
</header>
|
|
41
|
+
|
|
42
|
+
<div id="banner" class="banner" hidden></div>
|
|
43
|
+
|
|
44
|
+
<nav id="stage-rail" class="stage-rail" aria-label="Stages"></nav>
|
|
45
|
+
|
|
46
|
+
<section id="seats-panel" class="panel" aria-label="Seats">
|
|
47
|
+
<h2 class="panel-title">Seats</h2>
|
|
48
|
+
<table class="table">
|
|
49
|
+
<thead>
|
|
50
|
+
<tr><th>Seat</th><th>Role</th><th>Status</th><th>Stage</th><th>Msgs</th>
|
|
51
|
+
<th>Tokens in/out</th><th>Cost</th><th>Last activity</th><th></th></tr>
|
|
52
|
+
</thead>
|
|
53
|
+
<tbody id="seats-body"></tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<details id="reviews-panel" class="panel">
|
|
58
|
+
<summary class="panel-title">Stage-1 reviews</summary>
|
|
59
|
+
<div id="reviews-body" class="prose-host"></div>
|
|
60
|
+
</details>
|
|
61
|
+
|
|
62
|
+
<details id="bundle-panel" class="panel">
|
|
63
|
+
<summary class="panel-title">Peer-review packet (verbatim)</summary>
|
|
64
|
+
<div id="bundle-body" class="prose-host"></div>
|
|
65
|
+
</details>
|
|
66
|
+
|
|
67
|
+
<details id="judges-panel" class="panel">
|
|
68
|
+
<summary class="panel-title">Judge outputs</summary>
|
|
69
|
+
<div id="judges-body" class="prose-host"></div>
|
|
70
|
+
</details>
|
|
71
|
+
|
|
72
|
+
<section id="matrix-panel" class="panel" aria-label="Adjudication matrix">
|
|
73
|
+
<h2 class="panel-title">Adjudication matrix</h2>
|
|
74
|
+
<div id="matrix-body"></div>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<section id="verdict-panel" class="panel" aria-label="Verdict">
|
|
78
|
+
<h2 class="panel-title">Verdict</h2>
|
|
79
|
+
<div id="verdict-body"></div>
|
|
80
|
+
</section>
|
|
81
|
+
|
|
82
|
+
<section id="cost-panel" class="panel" aria-label="Cost">
|
|
83
|
+
<h2 class="panel-title">Cost by seat</h2>
|
|
84
|
+
<div id="cost-body"></div>
|
|
85
|
+
</section>
|
|
86
|
+
</div>
|
|
87
|
+
</main>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div id="dialog-abort" class="dialog-backdrop" hidden>
|
|
91
|
+
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-abort-title">
|
|
92
|
+
<h2 id="dialog-abort-title">Abort this council run?</h2>
|
|
93
|
+
<p>The active wave and solo legs will be aborted via the engine's abort path. Artifacts already on disk stay browsable.</p>
|
|
94
|
+
<div class="dialog-actions">
|
|
95
|
+
<button id="dialog-abort-cancel" class="btn">Cancel</button>
|
|
96
|
+
<button id="dialog-abort-confirm" class="btn danger">Abort run</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<script src="./md-lite.js"></script>
|
|
102
|
+
<script src="./live-model.js"></script>
|
|
103
|
+
<script src="./workspace-render.js"></script>
|
|
104
|
+
<script src="./workspace-matrix.js"></script>
|
|
105
|
+
<!-- ⚠️ DE-ROT (F05): panels + verbs are the Task 13 split of workspace-app.js.
|
|
106
|
+
Load order matters: workspace-app.js boots last and owns `state`. -->
|
|
107
|
+
<script src="./workspace-panels.js"></script>
|
|
108
|
+
<script src="./workspace-verbs.js"></script>
|
|
109
|
+
<script src="./workspace-app.js"></script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|