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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/sidecar/fanout-budget.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module sidecar/fanout-budget
|
|
6
|
+
* runFanout's pre-flight spend gate (§1b), extracted from src/sidecar/fanout.js —
|
|
7
|
+
* which sits three lines under the 300-line size gate and had no room for the
|
|
8
|
+
* v4.4 reservation seam. Behaviour of the extracted half is unchanged; the
|
|
9
|
+
* reservation is the only addition.
|
|
10
|
+
*
|
|
11
|
+
* WHY THE RESERVATION EXISTS (v4.4 cost-council finding 1). `checkBudget`
|
|
12
|
+
* compares the wave's pre-flight ESTIMATE against a `maxCost` NUMBER that the
|
|
13
|
+
* caller read at some earlier moment. The council driver launches Stage-1's seat
|
|
14
|
+
* wave and critic wave concurrently under a single `Promise.all`, and each
|
|
15
|
+
* launcher read `remainingBudget()` before EITHER wave's legs had been recorded
|
|
16
|
+
* — so both observed the full, unreduced allowance and both could pass a ceiling
|
|
17
|
+
* that only one of them fits under. The read is not the claim.
|
|
18
|
+
*
|
|
19
|
+
* `options.reserveBudget(estimate) -> boolean` closes that: it is a SYNCHRONOUS
|
|
20
|
+
* read-and-claim against the allowance not already claimed by a sibling wave
|
|
21
|
+
* that is mid-launch. Being synchronous is the whole guarantee — the event loop
|
|
22
|
+
* cannot interleave two callers inside it, so the second caller necessarily sees
|
|
23
|
+
* the first caller's claim. See src/council/run-budget.js for the ledger.
|
|
24
|
+
*
|
|
25
|
+
* It is OPT-IN: every non-council caller (the `amicus fanout` CLI, `amicus run`)
|
|
26
|
+
* omits it and gets the byte-identical pre-v4.4 gate.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Array<{modelInput,model,pricing}>} okLegs legs that actually routed
|
|
31
|
+
* @param {object} options runFanout options (maxCost, maxCostPerMtok, noCostGate,
|
|
32
|
+
* promptMeta/prompt, and the optional `reserveBudget` claim function)
|
|
33
|
+
* @returns {{ok:true, estimate?:number}|{ok:false, message:string, hint:string}}
|
|
34
|
+
*/
|
|
35
|
+
function preflightBudget(okLegs, options) {
|
|
36
|
+
// `--no-cost-gate` is a WHOLE-RUN opt-out of BOTH guards (an intentional
|
|
37
|
+
// o3-class council), so it must also skip the reservation — otherwise the
|
|
38
|
+
// council would still serialize its allowance for a ceiling it has disowned.
|
|
39
|
+
if (options.noCostGate) { return { ok: true }; }
|
|
40
|
+
|
|
41
|
+
const { checkBudget, formatBudgetError } = require('./budget');
|
|
42
|
+
const { loadConfig } = require('../utils/config');
|
|
43
|
+
const cfg = loadConfig() || {};
|
|
44
|
+
const maxCostPerMtok = options.maxCostPerMtok !== undefined ? options.maxCostPerMtok : cfg.maxCostPerMtok;
|
|
45
|
+
const promptChars = (options.promptMeta && options.promptMeta.chars)
|
|
46
|
+
|| (options.prompt ? options.prompt.length : 0);
|
|
47
|
+
const maxCost = options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost;
|
|
48
|
+
|
|
49
|
+
const budget = checkBudget(okLegs, { maxCostPerMtok, maxCost, promptChars });
|
|
50
|
+
if (!budget.ok) {
|
|
51
|
+
return { ok: false, message: 'Error: budget gate refused the wave', hint: formatBudgetError(budget) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Claim AFTER the hard per-$/Mtok threshold has passed: a wave that is going
|
|
55
|
+
// to be refused for an over-priced model must not consume allowance on its
|
|
56
|
+
// way out and starve a sibling that would have fit.
|
|
57
|
+
const estimate = budget.breakdown.totalEstCost;
|
|
58
|
+
if (typeof options.reserveBudget === 'function' && !options.reserveBudget(estimate)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
message: 'Error: budget gate refused the wave',
|
|
62
|
+
hint: `Budget gate: estimated total $${estimate.toFixed(4)} does not fit the --max-cost `
|
|
63
|
+
+ 'allowance still unclaimed by concurrently launching waves (estimate, not guaranteed).\n'
|
|
64
|
+
+ 'The run continues with the waves that did launch. Override: --max-cost <$> to raise the '
|
|
65
|
+
+ 'ceiling, or --no-cost-gate to disable both guards.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, estimate };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { preflightBudget };
|
|
@@ -132,7 +132,25 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
132
132
|
const status = legStatusFromResult(result);
|
|
133
133
|
const summary = result.summary || null;
|
|
134
134
|
const { resolveUsage } = require('../utils/pricing');
|
|
135
|
-
|
|
135
|
+
// v4.4 Task 2 (B4) + v4.4.1 CA-1: a leg that made a SUBAGENT (`task`) call has
|
|
136
|
+
// spend in a child OpenCode session that is billed separately and is NOT
|
|
137
|
+
// rolled into the parent session's cost. runHeadless now WALKS those sessions
|
|
138
|
+
// (src/sidecar/child-sessions.js), so `subtreeUnknown` narrows from "this leg
|
|
139
|
+
// called `task`, therefore assume the worst" to what it should always have
|
|
140
|
+
// meant: the walk could not account for the subtree.
|
|
141
|
+
//
|
|
142
|
+
// The walk is authoritative when it ran — including in the direction that
|
|
143
|
+
// CLEARS the flag, which the name-string proxy could never do (backlog CA-5:
|
|
144
|
+
// a tool merely NAMED `task` that spawns nothing used to make an exact run
|
|
145
|
+
// report itself inexact). The proxy survives only as the fallback for a leg
|
|
146
|
+
// where the walk could not run at all.
|
|
147
|
+
const walked = result && result.subtree;
|
|
148
|
+
const subtreeUnknown = walked
|
|
149
|
+
? !!walked.unknown
|
|
150
|
+
: !!(result && result.subagentToolCalls > 0);
|
|
151
|
+
const usage = result && result.usage
|
|
152
|
+
? resolveUsage({ model: leg.model, usageTotals: result.usage, subtreeUnknown, subtree: walked || undefined })
|
|
153
|
+
: null;
|
|
136
154
|
// If setup threw before the session dir existed, there is nothing on disk to
|
|
137
155
|
// finalize — still resolve to an error run document so the wave aggregates.
|
|
138
156
|
const legPatch = {
|
|
@@ -140,6 +158,10 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
140
158
|
reason: result.error || undefined,
|
|
141
159
|
completedAt: new Date().toISOString(),
|
|
142
160
|
usage: usage || undefined,
|
|
161
|
+
// v4.4 B4 part 1: the leg completed with tool calls still live, so its
|
|
162
|
+
// OpenCode session may have kept working (and billing) afterwards. Travels
|
|
163
|
+
// with the leg so it is readable long after the run's stderr is gone.
|
|
164
|
+
toolSettleTimedOut: (result && result.toolSettleTimedOut) || undefined,
|
|
143
165
|
};
|
|
144
166
|
let finalMeta = legPatch;
|
|
145
167
|
if (legDir) {
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -132,17 +132,10 @@ async function runFanout(options) {
|
|
|
132
132
|
|
|
133
133
|
// 1b. Budget gate (pre-creation; refuse before spending). Only legs that
|
|
134
134
|
// will actually run cost anything — a leg that never routed never spends.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const maxCostPerMtok = options.maxCostPerMtok !== undefined ? options.maxCostPerMtok : cfg.maxCostPerMtok;
|
|
140
|
-
const promptChars = (options.promptMeta && options.promptMeta.chars) || (options.prompt ? options.prompt.length : 0);
|
|
141
|
-
const budget = checkBudget(okLegs, { maxCostPerMtok, maxCost: options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost, promptChars });
|
|
142
|
-
if (!budget.ok) {
|
|
143
|
-
return failPre(ERROR_CODES.BUDGET_EXCEEDED, 'Error: budget gate refused the wave', formatBudgetError(budget));
|
|
144
|
-
}
|
|
145
|
-
}
|
|
135
|
+
// Lives in ./fanout-budget so the v4.4 concurrency reservation seam has room;
|
|
136
|
+
// that module's docblock carries the why.
|
|
137
|
+
const preflight = require('./fanout-budget').preflightBudget(okLegs, options);
|
|
138
|
+
if (!preflight.ok) { return failPre(ERROR_CODES.BUDGET_EXCEEDED, preflight.message, preflight.hint); }
|
|
146
139
|
|
|
147
140
|
// 2. Wave record
|
|
148
141
|
const waveId = options.waveId || generateTaskId();
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// src/sidecar/tool-part.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module sidecar/tool-part
|
|
6
|
+
* OpenCode's TOOL-PART shape and its status vocabulary (v4.4 B4 part 1).
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS. The mirror used to model a tool call as an Anthropic-style
|
|
9
|
+
* `tool_use` part cleared by a matching `tool_result` part carrying
|
|
10
|
+
* `tool_use_id`. **OpenCode has no such part type.** Established empirically
|
|
11
|
+
* before designing against it (see tests/sidecar/tool-part-status.test.js for
|
|
12
|
+
* the full derivation):
|
|
13
|
+
*
|
|
14
|
+
* - 5,129 persisted parts in this machine's OpenCode database resolve to
|
|
15
|
+
* exactly six `type` values: text, step-start, reasoning, step-finish,
|
|
16
|
+
* tool, patch. No `tool_result`. No `tool_use`.
|
|
17
|
+
* - The 35 recorded legs of `council-wsgate01/`..`council-wsgate04/` wrote
|
|
18
|
+
* **36 `tool_use` records and ZERO `tool_result` records**. So the
|
|
19
|
+
* diagnosis's proposed `pendingToolCalls.length === 0` gate, keyed on a
|
|
20
|
+
* `tool_result` that never arrives, would have hung every tool-using leg
|
|
21
|
+
* to its full `--timeout`.
|
|
22
|
+
*
|
|
23
|
+
* THE REAL SHAPE (@opencode-ai/sdk `ToolPart`):
|
|
24
|
+
* `{id, sessionID, messageID, type:'tool', callID, tool:<name>, state}`
|
|
25
|
+
* where `state` is the `ToolState` union.
|
|
26
|
+
*
|
|
27
|
+
* STATUS VOCABULARY:
|
|
28
|
+
* - DECLARED by the SDK's `ToolState` union: 'pending' | 'running' |
|
|
29
|
+
* 'completed' | 'error'.
|
|
30
|
+
* - OBSERVED across all 1,307 persisted tool parts: completed 1148,
|
|
31
|
+
* error 150, running 9. 'pending' is declared but never persisted — it is
|
|
32
|
+
* the pre-execution transient (`{input, raw}`, no `time` at all).
|
|
33
|
+
* - TERMINAL is 'completed' | 'error', verified structurally: all 1,298
|
|
34
|
+
* terminal parts carry `state.time.end`; all 9 non-terminal parts carry
|
|
35
|
+
* `state.time.start` and no `end`. Disjoint and exhaustive.
|
|
36
|
+
*
|
|
37
|
+
* The 9 `running` parts are LEFTOVERS — `time_updated` within milliseconds of
|
|
38
|
+
* `time_created`, all from three killed sessions that never wrote a terminal
|
|
39
|
+
* status. A stale non-terminal status can therefore persist forever, which is
|
|
40
|
+
* why any wait keyed on this vocabulary MUST be bounded (headless.js's
|
|
41
|
+
* TOOL_SETTLE_GRACE_MS).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The statuses that mean "this tool call is finished and its session is no
|
|
46
|
+
* longer working on it". Anything else — including an unrecognised or absent
|
|
47
|
+
* status — is treated as still live: terminality is never INVENTED, it must be
|
|
48
|
+
* positively observed.
|
|
49
|
+
* @type {Set<string>}
|
|
50
|
+
*/
|
|
51
|
+
const TERMINAL_TOOL_STATUSES = new Set(['completed', 'error']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The statuses that POSITIVELY tell us OpenCode is still working on a tool call.
|
|
55
|
+
*
|
|
56
|
+
* This is deliberately NOT `!TERMINAL` — the difference is the anti-hang
|
|
57
|
+
* guarantee. A tool part carrying no `state` at all (the legacy `tool_use`
|
|
58
|
+
* shape, which the repo's own fixtures still emit) gives us no evidence in
|
|
59
|
+
* either direction, and deferring a leg's completion on an ABSENCE of evidence
|
|
60
|
+
* is precisely how a completion gate hangs. So:
|
|
61
|
+
*
|
|
62
|
+
* - `getPendingToolCalls` = not-yet-terminal, INCLUDING unknown shape.
|
|
63
|
+
* Feeds B53's wedge detector, whose whole job is the no-evidence case, and
|
|
64
|
+
* whose semantics are therefore unchanged.
|
|
65
|
+
* - `getLiveToolCalls` = positively observed 'pending' or 'running'.
|
|
66
|
+
* Feeds the v4.4 completion gate: it will only ever hold a leg open on
|
|
67
|
+
* evidence that the session really is still working.
|
|
68
|
+
* @type {Set<string>}
|
|
69
|
+
*/
|
|
70
|
+
const LIVE_TOOL_STATUSES = new Set(['pending', 'running']);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Is this part a tool call at all? Accepts OpenCode's real `'tool'` type and
|
|
74
|
+
* the legacy `'tool_use'` shape that older fixtures/providers emit.
|
|
75
|
+
* @param {object} part
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
function isToolPart(part) {
|
|
79
|
+
return !!part && (part.type === 'tool' || part.type === 'tool_use');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The tool's name. Reads OpenCode's real field (`part.tool`) and falls back to
|
|
84
|
+
* the legacy `part.name`. The mirror previously read ONLY `part.name`, which
|
|
85
|
+
* the real shape never has — which is why all 36 recorded tool records carry an
|
|
86
|
+
* id and nothing else, and why headless.js's `Task`-subagent summary log was
|
|
87
|
+
* permanently empty.
|
|
88
|
+
* @param {object} part
|
|
89
|
+
* @returns {string|undefined}
|
|
90
|
+
*/
|
|
91
|
+
function toolPartName(part) {
|
|
92
|
+
if (!part) { return undefined; }
|
|
93
|
+
return part.tool !== undefined ? part.tool : part.name;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The tool's input. Real shape carries it on `state.input`; the legacy shape on
|
|
98
|
+
* `part.input`.
|
|
99
|
+
* @param {object} part
|
|
100
|
+
* @returns {object|undefined}
|
|
101
|
+
*/
|
|
102
|
+
function toolPartInput(part) {
|
|
103
|
+
if (!part) { return undefined; }
|
|
104
|
+
if (part.state && part.state.input !== undefined) { return part.state.input; }
|
|
105
|
+
return part.input;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Has this tool call reached a TERMINAL status? A part with no `state` (the
|
|
110
|
+
* legacy shape) is NOT settled — unknown status must never read as "finished",
|
|
111
|
+
* which is what keeps B53's wedge detection working on legacy fixtures.
|
|
112
|
+
* @param {object} part
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
function isToolPartSettled(part) {
|
|
116
|
+
const status = toolPartStatus(part);
|
|
117
|
+
return typeof status === 'string' && TERMINAL_TOOL_STATUSES.has(status);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The observed `state.status`, or undefined when the part carries no state
|
|
122
|
+
* (legacy shape) — "unknown", which is NOT the same as either terminal or live.
|
|
123
|
+
* @param {object} part
|
|
124
|
+
* @returns {string|undefined}
|
|
125
|
+
*/
|
|
126
|
+
function toolPartStatus(part) {
|
|
127
|
+
const status = part && part.state && part.state.status;
|
|
128
|
+
return typeof status === 'string' && status !== '' ? status : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Is this tool call POSITIVELY observed as still executing? See
|
|
133
|
+
* LIVE_TOOL_STATUSES for why this is not simply `!isToolPartSettled`.
|
|
134
|
+
* @param {object} part
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
function isToolPartLive(part) {
|
|
138
|
+
const status = toolPartStatus(part);
|
|
139
|
+
return status !== undefined && LIVE_TOOL_STATUSES.has(status);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Is this a SUBAGENT (child-session) tool call? OpenCode names it `task`
|
|
144
|
+
* (lowercase — the pre-existing `t.name === 'Task'` comparison in headless.js
|
|
145
|
+
* could never match, a second bug on top of the missing `part.tool` read).
|
|
146
|
+
*
|
|
147
|
+
* This is the signal for B4: a `task` call spawns a CHILD OpenCode session
|
|
148
|
+
* whose spend is billed separately and is NOT rolled into the parent session's
|
|
149
|
+
* cost, so a leg that made one has cost we cannot claim to know. Verified 1:1
|
|
150
|
+
* on the recorded corpus — exactly 2 `task` calls across 37 sessions, and
|
|
151
|
+
* exactly 2 child sessions, each parented by the calling leg's session
|
|
152
|
+
* (wsgate01-s1-2 → $0.021460, wsgate02-s1-3 → $0.471046).
|
|
153
|
+
* @param {{name?: string}} toolCall a recorded {id,name,input} entry
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function isSubagentToolCall(toolCall) {
|
|
157
|
+
const name = toolCall && toolCall.name;
|
|
158
|
+
return typeof name === 'string' && name.toLowerCase() === 'task';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Tool calls that have NOT reached a terminal `state.status` — as far as we can
|
|
163
|
+
* observe, OpenCode may still be working on them and the session may still be
|
|
164
|
+
* billing. INCLUDES the unknown-shape case (no `state` at all). Feeds the
|
|
165
|
+
* headless poll loop's B53 wedge detector, whose whole purpose is that case.
|
|
166
|
+
*
|
|
167
|
+
* v4.4 B4 part 1: this used to clear only on a `tool_result` part carrying
|
|
168
|
+
* `tool_use_id` — a part type OpenCode never emits (0 of 36 recorded tool
|
|
169
|
+
* records) — so it never cleared for any real leg. It is now driven by the real
|
|
170
|
+
* `state.status` vocabulary, with the legacy `tool_result` path kept for
|
|
171
|
+
* back-compat. Returns a fresh array; `state.pendingToolCalls` is the live
|
|
172
|
+
* source of truth.
|
|
173
|
+
* @param {{pendingToolCalls: Map}} state from createMirrorState()
|
|
174
|
+
* @returns {Array<{id:string,name:string,status:string|undefined,firstSeenAt:string}>}
|
|
175
|
+
*/
|
|
176
|
+
function getPendingToolCalls(state) {
|
|
177
|
+
return Array.from(state.pendingToolCalls.values());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Tool calls POSITIVELY observed as still executing. A strict SUBSET of
|
|
182
|
+
* getPendingToolCalls — see LIVE_TOOL_STATUSES for why the difference is the
|
|
183
|
+
* anti-hang guarantee. This is what the v4.4 completion gate reads.
|
|
184
|
+
* @param {{pendingToolCalls: Map}} state from createMirrorState()
|
|
185
|
+
* @returns {Array<{id:string,name:string,status:string,firstSeenAt:string}>}
|
|
186
|
+
*/
|
|
187
|
+
function getLiveToolCalls(state) {
|
|
188
|
+
return getPendingToolCalls(state)
|
|
189
|
+
.filter((t) => t.status !== undefined && LIVE_TOOL_STATUSES.has(t.status));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
TERMINAL_TOOL_STATUSES, LIVE_TOOL_STATUSES, isToolPart, toolPartName, toolPartInput,
|
|
194
|
+
toolPartStatus, isToolPartSettled, isToolPartLive, isSubagentToolCall,
|
|
195
|
+
getPendingToolCalls, getLiveToolCalls,
|
|
196
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Council Workspace launcher (v4.4 §4.3/§4.4) — setup-window.js pattern:
|
|
3
|
+
* ensureElectron() (the one place provisioning is allowed, #55) → spawn
|
|
4
|
+
* electron/main.js in council-workspace mode. Unlike setup (which buffers),
|
|
5
|
+
* this RELAYS child stdout live — the nonced fold block must reach the
|
|
6
|
+
* launching terminal's command output verbatim. Exit code propagates
|
|
7
|
+
* (0 on fold-then-close and on plain close).
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { spawn } = require('child_process');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { logger } = require('../utils/logger');
|
|
14
|
+
const { getElectronPath } = require('./interactive-process');
|
|
15
|
+
const { ensureElectron } = require('./electron-ensure');
|
|
16
|
+
const { generateFoldNonce } = require('../utils/fold-marker');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{project: string, runId?: string}} opts
|
|
20
|
+
* @param {{ensureElectron?: Function, spawn?: Function, nonce?: string}} [deps] test injection
|
|
21
|
+
* @returns {Promise<{code: number, error?: string}>}
|
|
22
|
+
*/
|
|
23
|
+
async function launchWorkspaceWindow({ project, runId = '' }, deps = {}) {
|
|
24
|
+
const ensure = deps.ensureElectron || ensureElectron;
|
|
25
|
+
const spawnFn = deps.spawn || spawn;
|
|
26
|
+
const ensured = await ensure();
|
|
27
|
+
if (!ensured.ok) {
|
|
28
|
+
return { code: 1, error: ensured.reason || 'Electron not installed' };
|
|
29
|
+
}
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const electronPath = ensured.path || getElectronPath();
|
|
32
|
+
const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
|
|
33
|
+
const env = {
|
|
34
|
+
...process.env,
|
|
35
|
+
AMICUS_MODE: 'council-workspace',
|
|
36
|
+
AMICUS_PROJECT: project,
|
|
37
|
+
AMICUS_RUN_ID: runId || '',
|
|
38
|
+
AMICUS_FOLD_NONCE: deps.nonce || generateFoldNonce(),
|
|
39
|
+
};
|
|
40
|
+
const debugPort = process.env.AMICUS_DEBUG_PORT;
|
|
41
|
+
const args = debugPort ? [`--remote-debugging-port=${debugPort}`, mainPath] : [mainPath];
|
|
42
|
+
logger.info('Launching council workspace', { runId: runId || '(run list)', debugPort: debugPort || 'disabled' });
|
|
43
|
+
|
|
44
|
+
const proc = spawnFn(electronPath, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
45
|
+
|
|
46
|
+
proc.stdout.setEncoding('utf-8');
|
|
47
|
+
proc.stdout.on('data', (chunk) => { process.stdout.write(chunk); }); // fold relay — live, verbatim
|
|
48
|
+
proc.stderr.setEncoding('utf-8');
|
|
49
|
+
proc.stderr.on('data', (chunk) => { logger.debug('Workspace stderr', { data: String(chunk).trim() }); });
|
|
50
|
+
|
|
51
|
+
proc.on('error', (err) => {
|
|
52
|
+
logger.error('Workspace failed to spawn', { error: err.message });
|
|
53
|
+
resolve({ code: 1, error: `Failed to start workspace: ${err.message}` });
|
|
54
|
+
});
|
|
55
|
+
proc.on('close', (code) => {
|
|
56
|
+
logger.info('Workspace closed', { code });
|
|
57
|
+
resolve({ code: code === null ? 1 : code });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { launchWorkspaceWindow };
|
package/src/spend-query.js
CHANGED
|
@@ -59,17 +59,26 @@ function rowKey(row, dimension) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Group rows into {key, amount, tokens, runs, unpricedRows, sourceMix},
|
|
64
|
+
* most-expensive first.
|
|
65
|
+
*
|
|
66
|
+
* v4.4: `amount` deliberately stays a plain number — the published
|
|
67
|
+
* spend.schema.json pins `groups[].amount` to `type: "number"` — so
|
|
68
|
+
* `unpricedRows` is how a group says "this figure omits N rows we cannot
|
|
69
|
+
* price". Without it, a group of entirely unpriced rows was indistinguishable
|
|
70
|
+
* from a group that genuinely cost $0 (diagnosis §8).
|
|
71
|
+
*/
|
|
63
72
|
function groupRows(rows, dimension) {
|
|
64
73
|
const map = new Map();
|
|
65
74
|
for (const r of rows) {
|
|
66
75
|
const key = rowKey(r, dimension);
|
|
67
|
-
if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
|
|
76
|
+
if (!map.has(key)) { map.set(key, { key, amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, sourceMix: { reported: 0, estimated: 0, unknown: 0 } }); }
|
|
68
77
|
const b = map.get(key);
|
|
69
78
|
b.runs += 1;
|
|
70
79
|
addTokens(b.tokens, r.tokens);
|
|
71
80
|
const cost = r.cost || {};
|
|
72
|
-
if (typeof cost.amount === 'number') { b.amount += cost.amount; }
|
|
81
|
+
if (typeof cost.amount === 'number') { b.amount += cost.amount; } else { b.unpricedRows += 1; }
|
|
73
82
|
const src = (cost.source === 'reported' || cost.source === 'estimated') ? cost.source : 'unknown';
|
|
74
83
|
b.sourceMix[src] += 1;
|
|
75
84
|
}
|
|
@@ -87,16 +96,22 @@ function groupRows(rows, dimension) {
|
|
|
87
96
|
* a row); computeWasted intentionally drops it instead.
|
|
88
97
|
*/
|
|
89
98
|
function computeWasted(rows) {
|
|
90
|
-
const out = { amount: 0, tokens: emptyTokens(), runs: 0, byStatus: {} };
|
|
99
|
+
const out = { amount: 0, tokens: emptyTokens(), runs: 0, unpricedRows: 0, byStatus: {} };
|
|
91
100
|
for (const r of rows) {
|
|
92
101
|
if (r.status === 'complete' || !r.status) { continue; }
|
|
93
102
|
out.runs += 1;
|
|
94
103
|
addTokens(out.tokens, r.tokens);
|
|
95
|
-
|
|
104
|
+
// v4.4: null→0 here is arithmetic, not a claim. `unpricedRows` records how
|
|
105
|
+
// many failed rows we could not price so "wasted $X" is never mistaken for
|
|
106
|
+
// the whole loss (see groupRows for why `amount` stays a number).
|
|
107
|
+
const priced = r.cost && typeof r.cost.amount === 'number';
|
|
108
|
+
const amt = priced ? r.cost.amount : 0;
|
|
109
|
+
if (!priced) { out.unpricedRows += 1; }
|
|
96
110
|
out.amount += amt;
|
|
97
|
-
if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0 }; }
|
|
111
|
+
if (!out.byStatus[r.status]) { out.byStatus[r.status] = { amount: 0, runs: 0, unpricedRows: 0 }; }
|
|
98
112
|
out.byStatus[r.status].amount += amt;
|
|
99
113
|
out.byStatus[r.status].runs += 1;
|
|
114
|
+
if (!priced) { out.byStatus[r.status].unpricedRows += 1; }
|
|
100
115
|
}
|
|
101
116
|
return out;
|
|
102
117
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/utils/env-num.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module utils/env-num
|
|
6
|
+
* Numeric environment override that HONORS an explicit `0`.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS. The idiom `Number(process.env.X) || DEFAULT` is wrong for any
|
|
9
|
+
* knob whose "off" value is `0`, because `0` is falsy and is therefore rewritten
|
|
10
|
+
* back into `DEFAULT`. v4.4 shipped four such knobs — `AMICUS_USAGE_SETTLE_POLLS`,
|
|
11
|
+
* `AMICUS_USAGE_SETTLE_INTERVAL_MS`, `AMICUS_USAGE_SETTLE_CALL_TIMEOUT_MS` and
|
|
12
|
+
* `AMICUS_TOOL_SETTLE_GRACE_MS` — each documenting `0` as the disable switch in
|
|
13
|
+
* its docblock and (for the last one) in the CHANGELOG, while making that switch
|
|
14
|
+
* unreachable from the environment. The operator could read the escape hatch and
|
|
15
|
+
* not use it.
|
|
16
|
+
*
|
|
17
|
+
* SEMANTICS. An explicit, finite numeric value always wins, `0` included. Unset,
|
|
18
|
+
* blank/whitespace-only, and non-finite values fall back to the default:
|
|
19
|
+
* - blank matters because `Number('') === 0`, so a bare `export AMICUS_X=` would
|
|
20
|
+
* otherwise read as an intentional disable rather than the accident it is;
|
|
21
|
+
* - non-finite matters because `Number('Infinity')` would otherwise be fed
|
|
22
|
+
* straight into `setTimeout`/comparison arithmetic.
|
|
23
|
+
*
|
|
24
|
+
* NOT A BLANKET REPLACEMENT. Several older knobs (`AMICUS_POLL_INTERVAL_MS`,
|
|
25
|
+
* `AMICUS_STABLE_*_POLLS`, `AMICUS_TOOL_CALL_STALL_MS`, `AMICUS_MAX_SESSIONS`, …)
|
|
26
|
+
* deliberately keep `||`: `0` is not a documented escape hatch for any of them and
|
|
27
|
+
* honoring it would busy-loop a poller or silently disable a stall guard. Migrate a
|
|
28
|
+
* knob to this helper only when `0` is a value its call site actually understands.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} name environment variable name
|
|
31
|
+
* @param {number} dflt value used when unset / blank / non-finite
|
|
32
|
+
* @param {object} [env] environment object (test seam; defaults to process.env)
|
|
33
|
+
* @returns {number}
|
|
34
|
+
*/
|
|
35
|
+
function envNumber(name, dflt, env) {
|
|
36
|
+
const raw = (env || process.env)[name];
|
|
37
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') { return dflt; }
|
|
38
|
+
const n = Number(raw);
|
|
39
|
+
return Number.isFinite(n) ? n : dflt;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { envNumber };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared realpath-containment fence.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for "is this resolved path inside that resolved
|
|
5
|
+
* directory" — the primitive that defeats symlink escapes AND tampered/stale
|
|
6
|
+
* pointer files (a `council-<id>.json` pointer's `runDir` is validated only
|
|
7
|
+
* for truthiness by src/council/run-state.js's readPointer, so nothing
|
|
8
|
+
* upstream of this check guarantees it stays inside the project).
|
|
9
|
+
*
|
|
10
|
+
* It is a LEAF: `fs` + `path` and nothing else, no require cycle possible.
|
|
11
|
+
* That was first needed inside the v4.4 workspace layer —
|
|
12
|
+
* src/workspace/artifact-guard.js requires src/workspace/run-scan.js for
|
|
13
|
+
* readPointer, so if run-scan.js also required artifact-guard.js for this
|
|
14
|
+
* helper, the two would require each other and one side's destructured import
|
|
15
|
+
* would silently resolve to undefined depending on load order.
|
|
16
|
+
*
|
|
17
|
+
* It lives in src/utils/ rather than src/workspace/ because its consumers are
|
|
18
|
+
* no longer all workspace modules: the shipped v4.3 surfaces
|
|
19
|
+
* (src/mcp-council-awareness.js behind amicus_status / amicus_abort /
|
|
20
|
+
* amicus_list, src/cli-handlers-watch.js and src/observe/watch-render.js behind
|
|
21
|
+
* `amicus watch`) fence the same pointer with the same check. Keeping it under
|
|
22
|
+
* src/workspace/ would have made three stable shipped surfaces depend on a
|
|
23
|
+
* feature directory added in v4.4 — the only inverted require in the tree, and
|
|
24
|
+
* one that would turn any future reorganisation of that layer into a breaking
|
|
25
|
+
* change for those tools. src/utils/ is the neutral layer src/workspace/
|
|
26
|
+
* already depends on (formatCost, fold-marker), so the arrow now points one way.
|
|
27
|
+
*/
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when `targetRealPath` is exactly `dirRealPath` or a proper descendant
|
|
35
|
+
* of it. Both arguments MUST already be resolved through realpathSync — this
|
|
36
|
+
* is a pure string-prefix check.
|
|
37
|
+
* @param {string} dirRealPath
|
|
38
|
+
* @param {string} targetRealPath
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function isRealpathContained(dirRealPath, targetRealPath) {
|
|
42
|
+
const dir = String(dirRealPath);
|
|
43
|
+
const target = String(targetRealPath);
|
|
44
|
+
if (target === dir) { return true; }
|
|
45
|
+
// ⚠️ COUNCIL REVIEW R2 (A6): when dirRealPath IS a filesystem root, it already
|
|
46
|
+
// ends in a separator ('/' on POSIX, 'C:\\' on Windows) — blindly appending
|
|
47
|
+
// another (the old `dirRealPath + path.sep`) doubles it ('//' / 'C:\\\\'), and
|
|
48
|
+
// no real path ever starts with that, so containment silently returned false
|
|
49
|
+
// for every path under a root dirRealPath. Only append the separator when it
|
|
50
|
+
// isn't already there.
|
|
51
|
+
const base = dir.endsWith(path.sep) ? dir : dir + path.sep;
|
|
52
|
+
// The separator-qualified prefix (not a bare `startsWith(dir)`) is what defeats
|
|
53
|
+
// the sibling-prefix trap: '/foobar' must not be considered inside '/foo'.
|
|
54
|
+
return target.startsWith(base);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fail-closed, disk-resolving form of isRealpathContained: resolves BOTH
|
|
59
|
+
* arguments through realpathSync and applies the same containment test.
|
|
60
|
+
* Returns false when either side cannot be resolved — a missing directory, a
|
|
61
|
+
* dangling symlink, a permission error, or a non-string `targetPath` straight
|
|
62
|
+
* out of a hand-edited pointer file — so an unresolvable path is REFUSED
|
|
63
|
+
* rather than trusted.
|
|
64
|
+
*
|
|
65
|
+
* The v4.4 workspace consumers keep calling isRealpathContained directly
|
|
66
|
+
* because each has to tell "unreadable" apart from "escapes" in the error row
|
|
67
|
+
* it renders. The v4.3 CLI/MCP consumers (src/mcp-council-awareness.js,
|
|
68
|
+
* src/cli-handlers-watch.js, src/observe/watch-render.js) collapse every
|
|
69
|
+
* failure into one outcome — no payload / skip the row / kind 'unknown' — so
|
|
70
|
+
* they take this boolean form instead of repeating the two try/catch blocks at
|
|
71
|
+
* four more call sites.
|
|
72
|
+
* @param {string} dirPath
|
|
73
|
+
* @param {string} targetPath
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
function containsOnDisk(dirPath, targetPath) {
|
|
77
|
+
try {
|
|
78
|
+
return isRealpathContained(fs.realpathSync(dirPath), fs.realpathSync(targetPath));
|
|
79
|
+
} catch { return false; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { isRealpathContained, containsOnDisk };
|