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,183 @@
|
|
|
1
|
+
// src/observe/council-legs.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module observe/council-legs
|
|
6
|
+
* Per-leg row builder for the composed council live doc
|
|
7
|
+
* (buildCouncilStatusPayload in src/mcp-council-awareness.js). Split out of
|
|
8
|
+
* that file to stay under the 300-line gate (DE-ROT Task 0.5, closes F01:
|
|
9
|
+
* `usageLegs` was computed then discarded — no `legs[]` ever reached the
|
|
10
|
+
* payload, so the live Seats panel had no data source).
|
|
11
|
+
*
|
|
12
|
+
* One row per leg id, built UNCONDITIONALLY: unlike the usage rollup (gated
|
|
13
|
+
* on `enriched.usage` in buildCouncilStatusPayload), a live seats panel needs
|
|
14
|
+
* just-started legs — the ones with no usage yet — just as much as priced
|
|
15
|
+
* ones. Field names mirror the wave branch (src/mcp-server.js:592-608) so
|
|
16
|
+
* live-normalize.js (Task 14) has one vocabulary to map, not two.
|
|
17
|
+
*
|
|
18
|
+
* `modelInput` + `role` (F36/F34 correction): a live leg's `model` is the
|
|
19
|
+
* RESOLVED executable id (metadata.model), never the council ALIAS that
|
|
20
|
+
* run.json's bench/chair/critic/lenses and roleFor's rule are keyed on — so
|
|
21
|
+
* deriving role from `model` is a silent no-op (Role column permanently
|
|
22
|
+
* em-dash) and blind mode's labelOf(alias) lookup never matches (real model
|
|
23
|
+
* id leaks). The alias IS on disk per-leg, though: every council leg goes
|
|
24
|
+
* through src/sidecar/fanout-leg.js's runSingleAttempt, which calls
|
|
25
|
+
* `writeLegPatch(legDir, { parentWave, modelInput })` synchronously,
|
|
26
|
+
* immediately after leg creation (fanout-leg.js:101) — well before any
|
|
27
|
+
* status poll could reasonably observe it missing. So this module reads
|
|
28
|
+
* `modelInput` straight off the leg's own metadata.json, no run.json join
|
|
29
|
+
* needed for the alias itself.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
const { readProgress, isStalled } = require('../sidecar/progress');
|
|
35
|
+
const { enrichLegUsage, TERMINAL } = require('./live-doc');
|
|
36
|
+
const { roleFor } = require('../council/run-stages');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A leg's council role. The chair stage is the one case alias identity
|
|
40
|
+
* cannot resolve: run-chair.js's fallback chain (ch1/ch2 = run.chair's own
|
|
41
|
+
* alias, ch3 = a DIFFERENT ledger-promoted alias, ch4 = whichever succeeded)
|
|
42
|
+
* means a chair leg's modelInput does not reliably equal run.json's `chair`
|
|
43
|
+
* field while the chain is still in flight (that field is only checkpointed
|
|
44
|
+
* once the WHOLE chain resolves, src/council/run-chair.js:122) — so alias
|
|
45
|
+
* matching would miss ch3/ch4 mid-run. The stage that owns the leg is the
|
|
46
|
+
* authoritative signal instead (plan's F34 correction: "derive role from the
|
|
47
|
+
* stage that owns the leg"). Every other stage (stage1/stage2/debate-*)
|
|
48
|
+
* reuses roleFor (src/council/run-stages.js) keyed on modelInput — a
|
|
49
|
+
* model's seat/critic/lens identity is stable whether it's reviewing
|
|
50
|
+
* (stage1) or judging (stage2), and a repair/debate leg relaunches the SAME
|
|
51
|
+
* alias as its origin leg, so the identity carries through unchanged.
|
|
52
|
+
* @returns {string|null} null when modelInput is unknown (truthful — never a guess)
|
|
53
|
+
*/
|
|
54
|
+
function legRole({ bench, critic, lenses, stageName, modelInput }) {
|
|
55
|
+
if (!modelInput) { return null; }
|
|
56
|
+
if (stageName === 'chair') { return 'chair'; }
|
|
57
|
+
return roleFor({ models: bench, critic, lenses }, modelInput);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One composed row for a leg, plus the ms-since-activity behind its `stalled`
|
|
62
|
+
* flag (or null when not stalled/not yet measurable) so the caller can roll
|
|
63
|
+
* several legs' staleness into one run-level summary without re-parsing
|
|
64
|
+
* `lastActivityAt` back into a timestamp.
|
|
65
|
+
* @param {string} project
|
|
66
|
+
* @param {string} legId
|
|
67
|
+
* @param {{bench: string[], critic: string|null, lenses: string[]|null, stageName: string}} runCtx
|
|
68
|
+
* @returns {{row: object, stalledMs: number|null}}
|
|
69
|
+
*/
|
|
70
|
+
function buildLegRow(project, legId, runCtx) {
|
|
71
|
+
const { getSessionDir } = require('../session-manager');
|
|
72
|
+
const legDir = getSessionDir(project, legId);
|
|
73
|
+
let meta = {};
|
|
74
|
+
try { meta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8')); }
|
|
75
|
+
catch { /* leg metadata not written yet — just-started leg */ }
|
|
76
|
+
// Truthful null, never metadata.model as a fallback: showing the resolved
|
|
77
|
+
// id where the alias was expected is exactly the F36 bug (blind mode would
|
|
78
|
+
// leak the real model id instead of degrading to an em-dash).
|
|
79
|
+
const modelInput = meta.modelInput || null;
|
|
80
|
+
const row = {
|
|
81
|
+
taskId: legId, model: meta.model || null, status: meta.status || 'unknown',
|
|
82
|
+
modelInput, role: legRole({ ...runCtx, modelInput }),
|
|
83
|
+
};
|
|
84
|
+
let stalledMs = null;
|
|
85
|
+
let p = null;
|
|
86
|
+
try {
|
|
87
|
+
p = readProgress(legDir);
|
|
88
|
+
row.messages = p.messages;
|
|
89
|
+
row.stage = p.stage;
|
|
90
|
+
row.latestPreview = p.latestPreview;
|
|
91
|
+
row.lastActivityAt = p.lastActivityAt;
|
|
92
|
+
row.stalled = row.status === 'running' && isStalled(p.lastActivityMs);
|
|
93
|
+
if (row.stalled) { stalledMs = p.lastActivityMs; }
|
|
94
|
+
} catch { /* no progress.json yet — a just-started leg; base fields only. */ }
|
|
95
|
+
|
|
96
|
+
// council review C3: this is a SEPARATE try from readProgress's above, on
|
|
97
|
+
// purpose. The old code wrapped both in one try, so a pricing-resolution
|
|
98
|
+
// failure (an unknown model, a corrupt catalog row) landed in the exact same
|
|
99
|
+
// catch as "progress.json doesn't exist yet" — an operator (or the workspace's
|
|
100
|
+
// cost-by-seat panel) could not tell "pricing lookup failed" from "hasn't
|
|
101
|
+
// billed yet"; both rendered as a permanently blank cost cell. `usageError`
|
|
102
|
+
// makes the failure mode truthful and distinguishable, additive to the row
|
|
103
|
+
// (N3's undefined-key discipline still holds: only set usage/usageError when
|
|
104
|
+
// there is something to say).
|
|
105
|
+
//
|
|
106
|
+
// v4.4 B3 (diagnosis §4/§7.3): for a TERMINAL leg, metadata.json's `usage`
|
|
107
|
+
// block wins over the progress.json snapshot. progress.json's usage is stamped
|
|
108
|
+
// ONLY on 'receiving' flushes — which fire on text/tool/reasoning GROWTH, i.e.
|
|
109
|
+
// always strictly before OpenCode's finalization stamp — so on real paid runs
|
|
110
|
+
// 31 of 35 legs ended with an all-zero snapshot while metadata.json held
|
|
111
|
+
// thousands of real tokens and a reported cost. Reading the snapshot for a
|
|
112
|
+
// finished leg made every completed seat look free in the live doc.
|
|
113
|
+
// headless.js now also writes a terminal 'complete' progress record carrying
|
|
114
|
+
// the settled usage, which fixes the DATA; this makes the READER prefer the
|
|
115
|
+
// authoritative source either way, including for every leg already on disk.
|
|
116
|
+
// A still-RUNNING leg keeps reading progress.json — metadata.usage does not
|
|
117
|
+
// exist until the leg finalizes, and read-time resolution is what keeps a live
|
|
118
|
+
// in-flight cost current (live-doc.js's stated design).
|
|
119
|
+
if (TERMINAL.has(row.status) && meta.usage && meta.usage.cost) {
|
|
120
|
+
row.usage = meta.usage;
|
|
121
|
+
} else if (p && p.usage) {
|
|
122
|
+
try {
|
|
123
|
+
const enriched = enrichLegUsage(row, p.usage);
|
|
124
|
+
if (enriched.usage) { row.usage = enriched.usage; }
|
|
125
|
+
} catch (err) {
|
|
126
|
+
row.usageError = err.message;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { row, stalledMs };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Rows for every leg id, plus a run-level stall rollup. A council run fans
|
|
134
|
+
* legs across several parallel sub-waves at once (seat wave, chair chain,
|
|
135
|
+
* lens/critic solos), so — unlike the single-leg wave branch, which only
|
|
136
|
+
* ever flags its own row — a run-level banner needs one summary rather than
|
|
137
|
+
* making the caller inspect every row.
|
|
138
|
+
*
|
|
139
|
+
* The rollup means exactly what the workspace's dead-run banner claims ("no
|
|
140
|
+
* leg activity for Xm — the run may be dead"): the run is stalled only when
|
|
141
|
+
* EVERY still-running leg is stalled. A single stalled leg alongside another
|
|
142
|
+
* leg that is actively producing (message/cost climbing) must NOT set the
|
|
143
|
+
* flag — that was the old (max-based) bug, observed live: `glm` sat stalled
|
|
144
|
+
* for two minutes while `qwen-coder` was visibly working, and the banner
|
|
145
|
+
* fired anyway. A leg that has already finished (any non-'running' status)
|
|
146
|
+
* is terminal — it is excluded from the "every leg" population entirely: it
|
|
147
|
+
* can neither keep the run "healthy" by counting as active, nor drag it
|
|
148
|
+
* "dead" by counting as stalled. Per-leg `row.stalled` is untouched by this —
|
|
149
|
+
* it stays the accurate, per-row signal it already was.
|
|
150
|
+
*
|
|
151
|
+
* When the run genuinely is stalled, `stalledForSeconds` is the SHORTEST
|
|
152
|
+
* idle duration among the stalled running legs, not the longest: that is the
|
|
153
|
+
* honest answer to "how long has the whole run been quiet" — something was
|
|
154
|
+
* still happening as recently as the most-recently-active (but still
|
|
155
|
+
* over-threshold) leg's last activity.
|
|
156
|
+
* @param {string} project
|
|
157
|
+
* @param {string[]} legIds
|
|
158
|
+
* @param {{bench: string[], critic: string|null, lenses: string[]|null, stageName: string}} runCtx
|
|
159
|
+
* run.json's alias-valued fields (bench/critic/lenses) + the active stage's
|
|
160
|
+
* name, threaded through to legRole — this module never reads run.json itself.
|
|
161
|
+
* @returns {{rows: object[], stalled?: true, stalledForSeconds?: number}}
|
|
162
|
+
*/
|
|
163
|
+
function buildLegRows(project, legIds, runCtx) {
|
|
164
|
+
const rows = [];
|
|
165
|
+
let runningCount = 0;
|
|
166
|
+
const stalledRunningMs = [];
|
|
167
|
+
for (const legId of legIds) {
|
|
168
|
+
const { row, stalledMs } = buildLegRow(project, legId, runCtx);
|
|
169
|
+
rows.push(row);
|
|
170
|
+
if (row.status === 'running') {
|
|
171
|
+
runningCount += 1;
|
|
172
|
+
if (stalledMs !== null) { stalledRunningMs.push(stalledMs); }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const out = { rows };
|
|
176
|
+
if (runningCount > 0 && stalledRunningMs.length === runningCount) {
|
|
177
|
+
out.stalled = true;
|
|
178
|
+
out.stalledForSeconds = Math.floor(Math.min(...stalledRunningMs) / 1000);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { buildLegRows };
|
package/src/observe/live-doc.js
CHANGED
|
@@ -17,11 +17,29 @@ const { resolveUsage, sumWaveUsage } = require('../utils/pricing');
|
|
|
17
17
|
|
|
18
18
|
const TERMINAL = new Set(['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout']);
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Attach read-time-resolved usage to a leg from its raw progress usage.
|
|
22
|
+
*
|
|
23
|
+
* v4.4.1 CA-1: the terminal progress record carries the leg's enumerated CHILD
|
|
24
|
+
* (subagent) session spend as `usage.subtree` / `usage.subtreeUnknown`
|
|
25
|
+
* (src/headless.js). Those must be forwarded, not dropped: the live workspace
|
|
26
|
+
* and `amicus watch` read progress.json directly, so silently keeping only
|
|
27
|
+
* {tokens, cost} here would make the GUI's cost-by-seat and its wave rollup
|
|
28
|
+
* disagree with run.json by exactly the child-session amount — reintroducing
|
|
29
|
+
* the under-report one surface down from where it was fixed.
|
|
30
|
+
*/
|
|
21
31
|
function enrichLegUsage(leg, progressUsage) {
|
|
22
32
|
if (!progressUsage || !progressUsage.tokens) { return leg; }
|
|
23
|
-
const resolved = resolveUsage({
|
|
24
|
-
|
|
33
|
+
const resolved = resolveUsage({
|
|
34
|
+
model: leg.model,
|
|
35
|
+
usageTotals: progressUsage,
|
|
36
|
+
subtree: progressUsage.subtree,
|
|
37
|
+
subtreeUnknown: progressUsage.subtreeUnknown,
|
|
38
|
+
});
|
|
39
|
+
const usage = { tokens: resolved.tokens, cost: resolved.cost };
|
|
40
|
+
if (resolved.subtree) { usage.subtree = resolved.subtree; }
|
|
41
|
+
if (resolved.subtreeUnknown) { usage.subtreeUnknown = true; }
|
|
42
|
+
return { ...leg, usage };
|
|
25
43
|
}
|
|
26
44
|
|
|
27
45
|
/** Stamp view:'live' on a non-terminal composed doc; no-op when terminal. */
|
|
@@ -93,6 +93,25 @@ function emitJsonChange(doc, prevText) {
|
|
|
93
93
|
* @returns {Promise<number>} exit code
|
|
94
94
|
*/
|
|
95
95
|
async function runWatchLoop(target, args, project, deps = {}) {
|
|
96
|
+
// Pointer-containment fence, defence in depth. cli-handlers-watch.js's
|
|
97
|
+
// resolveWatchTarget already refuses a council pointer whose runDir escapes
|
|
98
|
+
// the project, but this loop is exported, takes `target` from its caller, and
|
|
99
|
+
// opens events.jsonl straight out of target.runDir below — so it re-checks
|
|
100
|
+
// rather than trusting the hand-off. Reuses the shared fence
|
|
101
|
+
// (src/utils/path-fence.js) and reports through the SAME failJson
|
|
102
|
+
// BAD_SESSION envelope handleWatch uses for an unresolvable id, so a --json
|
|
103
|
+
// caller still gets exactly one typed error doc.
|
|
104
|
+
if (target.kind === 'council') {
|
|
105
|
+
const { containsOnDisk } = require('../utils/path-fence');
|
|
106
|
+
if (!containsOnDisk(project, target.runDir)) {
|
|
107
|
+
const { failJson, ERROR_CODES } = require('../utils/error-doc');
|
|
108
|
+
return failJson(!!args.json, {
|
|
109
|
+
code: ERROR_CODES.BAD_SESSION,
|
|
110
|
+
message: `watch: run directory for '${target.id}' resolves outside project ${project}`,
|
|
111
|
+
hint: 'Pass --project if the run was launched elsewhere.',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
96
115
|
const intervalSec = Math.max(0.5, Number(args.interval) || 2);
|
|
97
116
|
const statusFn = deps.statusFn || ((id, p) => require('../mcp-server').handlers.amicus_status({ taskId: id }, p));
|
|
98
117
|
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
package/src/opencode-client.js
CHANGED
|
@@ -328,15 +328,27 @@ async function createChildSession(client, parentId) {
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
/**
|
|
331
|
-
* Get child sessions for a parent session
|
|
331
|
+
* Get child (subagent) sessions for a parent session.
|
|
332
|
+
*
|
|
333
|
+
* ⚠️ v4.4.1 LC-7: this was the ONE per-session call that did not thread
|
|
334
|
+
* `directoryQuery(directory)`, unlike getMessages / createSession /
|
|
335
|
+
* abortSession. It went unnoticed because nothing called it — on a SHARED
|
|
336
|
+
* server (one server, many projects) an un-scoped call is the exact
|
|
337
|
+
* "session not found" failure mode issue #47 fixed everywhere else, and it
|
|
338
|
+
* would have broken child-session cost attribution on precisely the
|
|
339
|
+
* configuration that makes attribution matter. Fixed with CA-1, its first
|
|
340
|
+
* consumer (src/sidecar/child-sessions.js).
|
|
332
341
|
*
|
|
333
342
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
334
343
|
* @param {string} parentId - Parent session ID
|
|
344
|
+
* @param {string} [directory] - Optional project directory to scope the call to.
|
|
345
|
+
* Omitting it keeps the call byte-for-byte identical to before.
|
|
335
346
|
* @returns {Promise<Array>} Array of child sessions
|
|
336
347
|
*/
|
|
337
|
-
async function getChildren(client, parentId) {
|
|
348
|
+
async function getChildren(client, parentId, directory) {
|
|
338
349
|
const result = await client.session.children({
|
|
339
|
-
path: { id: parentId }
|
|
350
|
+
path: { id: parentId },
|
|
351
|
+
...directoryQuery(directory)
|
|
340
352
|
});
|
|
341
353
|
|
|
342
354
|
return result.data || [];
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// src/sidecar/child-sessions.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module sidecar/child-sessions
|
|
6
|
+
* v4.4.1 CA-1 (B4) — enumerate a leg's CHILD (subagent) sessions and total
|
|
7
|
+
* their spend, so it can be attributed to the parent leg instead of vanishing.
|
|
8
|
+
*
|
|
9
|
+
* THE DEFECT. A leg that calls the `task` tool spawns a child OpenCode session.
|
|
10
|
+
* OpenCode bills it separately and does NOT roll it into the parent session's
|
|
11
|
+
* cost, and amicus never enumerated it — so it was invisible to every total the
|
|
12
|
+
* product prints. Measured across the four recorded paid runs: **$0.492506**
|
|
13
|
+
* ($0.021460 in `council-wsgate01`, $0.471046 in `council-wsgate02`).
|
|
14
|
+
* `wsgate01` is the honest limit case: all 7 legs `source: 'reported'`,
|
|
15
|
+
* `unpricedLegs: 0`, and the run still 7.1% short — 100% of that gap was one
|
|
16
|
+
* `explore` child session.
|
|
17
|
+
*
|
|
18
|
+
* WHY IT IS SAFE TO DO THIS NOW. Both blockers named in
|
|
19
|
+
* `cost-pipeline-fix-report.md` §5.4/§5.7 are closed. The client capability was
|
|
20
|
+
* already there (`getChildren`, src/opencode-client.js — only `directory`
|
|
21
|
+
* scoping was missing, backlog LC-7, fixed with this change), and the
|
|
22
|
+
* premature-completion fix (`dcb0792`) means a `task` part goes terminal only
|
|
23
|
+
* once its child session ends, so enumerating at finalization can no longer
|
|
24
|
+
* capture a partial child cost and trade a silent zero for a silent floor.
|
|
25
|
+
*
|
|
26
|
+
* THE HONESTY RULE. Never fabricate a number. `complete: false` is returned
|
|
27
|
+
* whenever any part of the walk could not be carried out — an API failure, a
|
|
28
|
+
* bound, or a child whose spend cannot be stated — and the caller turns that
|
|
29
|
+
* into the leg's existing `subtreeUnknown` flag rather than into a zero. There
|
|
30
|
+
* is deliberately no fourth honesty concept here: what this module produces is
|
|
31
|
+
* either an attributable amount or the flag that already exists for "the
|
|
32
|
+
* subtree is not knowable".
|
|
33
|
+
*
|
|
34
|
+
* A child's price comes from the SAME field the parent's does: the assistant
|
|
35
|
+
* messages' `info.cost`. It is never estimated from a catalog — the SDK's
|
|
36
|
+
* Session record carries no model id, so estimating one would mean guessing
|
|
37
|
+
* which route billed it, and a guess is exactly what this module exists to
|
|
38
|
+
* avoid. A child that shows real tokens but reports no cost is therefore
|
|
39
|
+
* `priced: false` and makes the subtree incomplete (the B2 rule, one level
|
|
40
|
+
* down): work happened, and we cannot state what it cost.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const { getChildren, getMessages } = require('../opencode-client');
|
|
44
|
+
const { createMirrorState, mirrorUsageOnly } = require('./conversation-mirror');
|
|
45
|
+
const { sumPerMessageUsage, hasObservedTokens } = require('../utils/pricing');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bounds. Hard constants rather than env knobs: they exist to stop a pathological
|
|
49
|
+
* walk, not to be tuned. Real subagent trees observed in the corpus are one child
|
|
50
|
+
* deep and one wide; 4/64 is orders of magnitude of headroom, and exceeding either
|
|
51
|
+
* is reported as INCOMPLETE rather than silently truncated.
|
|
52
|
+
*/
|
|
53
|
+
const SUBTREE_MAX_DEPTH = 4;
|
|
54
|
+
const SUBTREE_MAX_SESSIONS = 64;
|
|
55
|
+
|
|
56
|
+
/** Resolve, or reject after `ms`. `ms <= 0` passes the promise through untouched. */
|
|
57
|
+
function withDeadline(promise, ms) {
|
|
58
|
+
if (!ms || ms <= 0) { return promise; }
|
|
59
|
+
let timer;
|
|
60
|
+
return Promise.race([
|
|
61
|
+
promise.then((v) => { clearTimeout(timer); return v; },
|
|
62
|
+
(e) => { clearTimeout(timer); throw e; }),
|
|
63
|
+
new Promise((_r, reject) => { timer = setTimeout(() => reject(new Error('subtree call timed out')), ms); }),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emptyTokens() {
|
|
68
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Total one child session's own usage from its assistant messages — the same
|
|
73
|
+
* capture path the parent leg uses (mirrorUsageOnly keys the latest snapshot per
|
|
74
|
+
* message id, so a re-read can never double-count).
|
|
75
|
+
* @returns {{tokens: object, costReported: number, priced: boolean}}
|
|
76
|
+
*/
|
|
77
|
+
function usageOfSession(messages) {
|
|
78
|
+
const state = createMirrorState();
|
|
79
|
+
mirrorUsageOnly(messages, state);
|
|
80
|
+
const totals = sumPerMessageUsage(state.usageByMsg);
|
|
81
|
+
return {
|
|
82
|
+
tokens: totals.tokens,
|
|
83
|
+
costReported: totals.costReported,
|
|
84
|
+
// "We can state this session's spend." Only a billed amount states it.
|
|
85
|
+
// Tokens with no cost are the child-level form of the B2 lie (`0 × catalog
|
|
86
|
+
// = estimated $0`) and cannot be priced here at all, because the SDK's
|
|
87
|
+
// Session record carries no model id — so they read as unknown, which is
|
|
88
|
+
// what they are. A session with neither tokens nor cost is not free either:
|
|
89
|
+
// we saw nothing, and nothing is not zero.
|
|
90
|
+
priced: totals.costReported > 0,
|
|
91
|
+
observed: hasObservedTokens(totals.tokens),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Walk `rootSessionId`'s child sessions (breadth-first, bounded, cycle-proof)
|
|
97
|
+
* and total their spend.
|
|
98
|
+
*
|
|
99
|
+
* @param {import('@opencode-ai/sdk').OpencodeClient} client
|
|
100
|
+
* @param {string} rootSessionId the LEG's own session id (never itself counted)
|
|
101
|
+
* @param {object} [opts]
|
|
102
|
+
* @param {string} [opts.directory] project dir; threaded into every call (#47/LC-7)
|
|
103
|
+
* @param {number} [opts.callTimeoutMs] per-call deadline; 0/absent = no extra timer
|
|
104
|
+
* @param {object} [opts.logger] optional logger for best-effort debug lines
|
|
105
|
+
* @returns {Promise<{sessions: Array<{id: string, tokens: object, costReported: number, priced: boolean}>,
|
|
106
|
+
* tokens: object, costReported: number, complete: boolean}>}
|
|
107
|
+
*/
|
|
108
|
+
async function collectSubtreeUsage(client, rootSessionId, opts = {}) {
|
|
109
|
+
const { directory, callTimeoutMs, logger } = opts;
|
|
110
|
+
const dirArgs = directory === undefined ? [] : [directory];
|
|
111
|
+
const tokens = emptyTokens();
|
|
112
|
+
const sessions = [];
|
|
113
|
+
const visited = new Set([rootSessionId]);
|
|
114
|
+
let costReported = 0;
|
|
115
|
+
let complete = true;
|
|
116
|
+
let frontier = [rootSessionId];
|
|
117
|
+
|
|
118
|
+
for (let depth = 0; depth < SUBTREE_MAX_DEPTH && frontier.length > 0; depth++) {
|
|
119
|
+
const next = [];
|
|
120
|
+
for (const parentId of frontier) {
|
|
121
|
+
let kids;
|
|
122
|
+
try {
|
|
123
|
+
kids = await withDeadline(getChildren(client, parentId, ...dirArgs), callTimeoutMs);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Could not enumerate: the subtree is unknown, not empty.
|
|
126
|
+
complete = false;
|
|
127
|
+
if (logger) { logger.debug('subtree: getChildren failed', { parentId, error: err.message }); }
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
for (const kid of Array.isArray(kids) ? kids : []) {
|
|
131
|
+
const id = kid && kid.id;
|
|
132
|
+
if (!id || visited.has(id)) { continue; }
|
|
133
|
+
if (visited.size > SUBTREE_MAX_SESSIONS) { complete = false; break; }
|
|
134
|
+
visited.add(id);
|
|
135
|
+
|
|
136
|
+
let messages;
|
|
137
|
+
try {
|
|
138
|
+
messages = await withDeadline(getMessages(client, id, ...dirArgs), callTimeoutMs);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
complete = false;
|
|
141
|
+
if (logger) { logger.debug('subtree: getMessages failed', { sessionId: id, error: err.message }); }
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const u = usageOfSession(messages);
|
|
145
|
+
sessions.push({ id, tokens: u.tokens, costReported: u.costReported, priced: u.priced });
|
|
146
|
+
if (u.priced) {
|
|
147
|
+
costReported += u.costReported;
|
|
148
|
+
for (const k of Object.keys(tokens)) { tokens[k] += u.tokens[k] || 0; }
|
|
149
|
+
} else {
|
|
150
|
+
// The child exists and we read it, but its spend is not stateable.
|
|
151
|
+
complete = false;
|
|
152
|
+
}
|
|
153
|
+
next.push(id);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// The frontier is non-empty at the last permitted depth only if there may be
|
|
157
|
+
// another level we are choosing not to walk — say so rather than imply the
|
|
158
|
+
// walk finished.
|
|
159
|
+
if (depth === SUBTREE_MAX_DEPTH - 1 && next.length > 0) { complete = false; }
|
|
160
|
+
frontier = next;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { sessions, tokens, costReported, complete };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Is this leg's subtree spend UNKNOWN, given everything we observed?
|
|
168
|
+
*
|
|
169
|
+
* Split out as a pure predicate because the calibration is the whole argument,
|
|
170
|
+
* and getting it wrong in either direction is a real defect:
|
|
171
|
+
*
|
|
172
|
+
* - Too eager and the flag cries wolf. An OpenCode server that does not serve
|
|
173
|
+
* `/session/{id}/children` fails EVERY walk, and a naive `!walkComplete`
|
|
174
|
+
* would then mark every leg of every run inexact forever — which is not
|
|
175
|
+
* honesty, it is noise that trains the reader to ignore the one run where it
|
|
176
|
+
* matters.
|
|
177
|
+
* - Too lax and it is the silent under-count the flag exists to kill.
|
|
178
|
+
*
|
|
179
|
+
* So it keys on EVIDENCE. A failed walk with no evidence of a subagent at all
|
|
180
|
+
* (no child found, no `task` call recorded) asserts nothing and flags nothing —
|
|
181
|
+
* exactly the pre-CA-1 behaviour. Any positive evidence of a subtree that the
|
|
182
|
+
* walk did not fully account for flags it.
|
|
183
|
+
*
|
|
184
|
+
* @param {{walkComplete: boolean, sessionsFound: number, subagentCalls: number}} obs
|
|
185
|
+
* @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
function subtreeIsUnknown({ walkComplete, sessionsFound, subagentCalls }) {
|
|
188
|
+
const found = sessionsFound > 0;
|
|
189
|
+
const expected = subagentCalls > 0;
|
|
190
|
+
// A clean walk still cannot be believed when it contradicts the other
|
|
191
|
+
// observation: a leg that demonstrably called `task` and yet has no child
|
|
192
|
+
// session means one of the two readings is wrong, and "there was nothing" is
|
|
193
|
+
// not the reading this evidence supports.
|
|
194
|
+
if (walkComplete) { return expected && !found; }
|
|
195
|
+
return found || expected;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { collectSubtreeUsage, subtreeIsUnknown, SUBTREE_MAX_DEPTH, SUBTREE_MAX_SESSIONS };
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// bump in the headless idle detector.
|
|
18
18
|
const MAX_TOOL_CALLS = 2000;
|
|
19
19
|
|
|
20
|
+
const toolPart = require('./tool-part');
|
|
21
|
+
const { isToolPart, toolPartName, toolPartInput, toolPartStatus, isToolPartSettled } = toolPart;
|
|
22
|
+
|
|
20
23
|
/** Fresh cursor for a session's mirror. */
|
|
21
24
|
function createMirrorState() {
|
|
22
25
|
return {
|
|
@@ -24,7 +27,8 @@ function createMirrorState() {
|
|
|
24
27
|
toolCalls: [], // [{id,name,input}] — capped at MAX_TOOL_CALLS (most-recent-N)
|
|
25
28
|
seenToolCallIds: new Set(), // stable dedup identity for tool calls (survives the cap)
|
|
26
29
|
seenToolResultIds: new Set(),
|
|
27
|
-
|
|
30
|
+
settledToolCallIds: new Set(), // ids that reached a TERMINAL state.status (v4.4 B4 part 1)
|
|
31
|
+
pendingToolCalls: new Map(), // id -> {id,name,firstSeenAt} — tool call not yet TERMINAL (B53/B4)
|
|
28
32
|
receivingReported: false,
|
|
29
33
|
output: '', // accumulated assistant text
|
|
30
34
|
seenReasoningParts: new Map(), // partId -> last captured reasoning length
|
|
@@ -34,16 +38,63 @@ function createMirrorState() {
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* is the live source of truth.
|
|
42
|
-
* @param {object} state from createMirrorState()
|
|
43
|
-
* @returns {Array<{id:string,name:string,firstSeenAt:string}>}
|
|
41
|
+
* Capture one assistant message's usage snapshot into `state.usageByMsg`.
|
|
42
|
+
* The poll loop re-reads ALL messages every poll, so the latest snapshot per
|
|
43
|
+
* message id wins (keyed Map, never additive) — see pricing.sumPerMessageUsage.
|
|
44
|
+
* @returns {boolean} true when this message carried a usage payload
|
|
44
45
|
*/
|
|
45
|
-
function
|
|
46
|
-
|
|
46
|
+
function captureMsgUsage(msg, state) {
|
|
47
|
+
if (msg.info.tokens || typeof msg.info.cost === 'number') {
|
|
48
|
+
state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* USAGE-ONLY mirror pass (v4.4 B1). Captures `info.tokens`/`info.cost` from a
|
|
56
|
+
* fresh getMessages() snapshot and NOTHING else — no appendLines, no
|
|
57
|
+
* `state.output` growth, no progress updates, no pending-tool bookkeeping.
|
|
58
|
+
*
|
|
59
|
+
* This exists because the headless poll loop's fast-path exits (trailing fold
|
|
60
|
+
* marker, SDK `idle`) break BEFORE OpenCode stamps usage at finalization, so a
|
|
61
|
+
* bounded post-loop re-read is required to see it. Calling the full
|
|
62
|
+
* mirrorMessages() there would append the already-mirrored assistant text to
|
|
63
|
+
* conversation.jsonl a second time; this function cannot, because it never
|
|
64
|
+
* touches seenTextParts/output at all.
|
|
65
|
+
* @param {Array} messages getMessages() snapshot
|
|
66
|
+
* @param {object} state from createMirrorState() (only usageByMsg is mutated)
|
|
67
|
+
* @returns {number} count of messages whose usage was captured
|
|
68
|
+
*/
|
|
69
|
+
function mirrorUsageOnly(messages, state) {
|
|
70
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
71
|
+
let captured = 0;
|
|
72
|
+
for (const msg of list) {
|
|
73
|
+
if (!msg || !msg.info || msg.info.role !== 'assistant') { continue; }
|
|
74
|
+
if (captureMsgUsage(msg, state)) { captured += 1; }
|
|
75
|
+
}
|
|
76
|
+
return captured;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Does EVERY assistant message in this snapshot carry a usage payload we can
|
|
81
|
+
* act on — a billed cost, or genuinely observed tokens (v4.4 B1 early-break)?
|
|
82
|
+
*
|
|
83
|
+
* Keyed on `hasObservedTokens` rather than "tokens object exists" so an
|
|
84
|
+
* all-zero placeholder block does not satisfy the predicate — that is exactly
|
|
85
|
+
* the pre-finalization state the settle loop is waiting out. A legitimately
|
|
86
|
+
* free local seat still reports real token counts, so it satisfies this on the
|
|
87
|
+
* first re-read and never pays the full settle window.
|
|
88
|
+
* @param {Array} messages
|
|
89
|
+
* @returns {boolean} false when there are no assistant messages at all
|
|
90
|
+
*/
|
|
91
|
+
function allAssistantUsagePresent(messages) {
|
|
92
|
+
const { hasObservedTokens } = require('../utils/pricing');
|
|
93
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
94
|
+
const assistants = list.filter((m) => m && m.info && m.info.role === 'assistant');
|
|
95
|
+
if (assistants.length === 0) { return false; }
|
|
96
|
+
return assistants.every((m) => (typeof m.info.cost === 'number' && m.info.cost > 0)
|
|
97
|
+
|| hasObservedTokens(m.info.tokens));
|
|
47
98
|
}
|
|
48
99
|
|
|
49
100
|
/**
|
|
@@ -68,9 +119,7 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
68
119
|
// Track assistant message state
|
|
69
120
|
if (role === 'assistant') {
|
|
70
121
|
currentAssistantMsgId = msg.info.id;
|
|
71
|
-
|
|
72
|
-
state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
|
|
73
|
-
}
|
|
122
|
+
captureMsgUsage(msg, state);
|
|
74
123
|
// Check for errors — capture for result propagation
|
|
75
124
|
if (msg.info.error) {
|
|
76
125
|
sessionError = (msg.info.error.data && msg.info.error.data.message)
|
|
@@ -99,29 +148,49 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
99
148
|
progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
|
|
100
149
|
}
|
|
101
150
|
}
|
|
102
|
-
} else if ((part
|
|
103
|
-
|
|
104
|
-
state.
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
151
|
+
} else if (isToolPart(part)) {
|
|
152
|
+
// v4.4 B4 part 1: this branch runs on EVERY poll, not only first sight,
|
|
153
|
+
// because a tool call's `state.status` transitions in place — that
|
|
154
|
+
// transition is the only signal OpenCode gives that the call finished
|
|
155
|
+
// (there is no tool_result part). Appending stays first-sight-only.
|
|
156
|
+
const toolName = toolPartName(part);
|
|
157
|
+
const firstSight = !state.seenToolCallIds.has(part.id);
|
|
158
|
+
if (firstSight) {
|
|
159
|
+
const toolCall = { id: part.id, name: toolName, input: toolPartInput(part) };
|
|
160
|
+
state.seenToolCallIds.add(part.id);
|
|
161
|
+
state.toolCalls.push(toolCall);
|
|
162
|
+
// Bound growth: keep the most recent N tool-call payloads (BL-4). Dedup is the
|
|
163
|
+
// Set above, so dropping the oldest here never causes a re-append.
|
|
164
|
+
if (state.toolCalls.length > MAX_TOOL_CALLS) { state.toolCalls.shift(); }
|
|
165
|
+
appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
|
|
166
|
+
|
|
167
|
+
// Update progress on tool-call detection
|
|
168
|
+
progressUpdates.push({
|
|
169
|
+
stage: 'receiving',
|
|
170
|
+
extra: {
|
|
171
|
+
messagesReceived: state.toolCalls.length,
|
|
172
|
+
latestTool: toolName || undefined,
|
|
173
|
+
stageLabel: toolName ? `Calling tool: ${toolName}` : 'Executing tool call...',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
state.receivingReported = true;
|
|
177
|
+
}
|
|
178
|
+
// Pending until the status is positively observed TERMINAL. firstSeenAt
|
|
179
|
+
// is captured once — never bumped while the call stays non-terminal, so
|
|
180
|
+
// B53's "pending for Ns" reason string stays honest.
|
|
181
|
+
if (isToolPartSettled(part)) {
|
|
182
|
+
state.pendingToolCalls.delete(part.id);
|
|
183
|
+
state.settledToolCallIds.add(part.id);
|
|
184
|
+
} else if (!state.settledToolCallIds.has(part.id)) {
|
|
185
|
+
const prior = state.pendingToolCalls.get(part.id);
|
|
186
|
+
state.pendingToolCalls.set(part.id, {
|
|
187
|
+
id: part.id, name: toolName,
|
|
188
|
+
// undefined = legacy shape, status unknown. Kept explicitly so
|
|
189
|
+
// getLiveToolCalls can tell "still running" from "no idea".
|
|
190
|
+
status: toolPartStatus(part),
|
|
191
|
+
firstSeenAt: prior ? prior.firstSeenAt : now(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
125
194
|
} else if (part.type === 'tool_result') {
|
|
126
195
|
// Dedup: append only on first sight (fixes latent double-log bug in headless poll loop)
|
|
127
196
|
if (!state.seenToolResultIds.has(partId)) {
|
|
@@ -199,4 +268,9 @@ function logMessage(conversationPath, message) {
|
|
|
199
268
|
fs.appendFileSync(conversationPath, JSON.stringify(message) + '\n', { mode: 0o600 });
|
|
200
269
|
}
|
|
201
270
|
|
|
202
|
-
module.exports = { createMirrorState, mirrorMessages, logMessage,
|
|
271
|
+
module.exports = { createMirrorState, mirrorMessages, logMessage,
|
|
272
|
+
mirrorUsageOnly, allAssistantUsagePresent,
|
|
273
|
+
// Re-exported from ./tool-part so callers have ONE import surface for the
|
|
274
|
+
// mirror's tool-call model (that module exists separately to keep this file
|
|
275
|
+
// under the 300-line size gate).
|
|
276
|
+
...toolPart };
|