amicus 4.2.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +46 -1
  3. package/README.md +8 -4
  4. package/bin/amicus.js +5 -0
  5. package/electron/ipc-workspace.js +283 -0
  6. package/electron/main.js +27 -0
  7. package/electron/preload-workspace.js +40 -0
  8. package/electron/workspace-shell.js +85 -0
  9. package/electron/workspace-ui/index.html +111 -0
  10. package/electron/workspace-ui/live-model.js +101 -0
  11. package/electron/workspace-ui/md-lite.js +119 -0
  12. package/electron/workspace-ui/workspace-app.js +240 -0
  13. package/electron/workspace-ui/workspace-matrix.js +212 -0
  14. package/electron/workspace-ui/workspace-panels.js +226 -0
  15. package/electron/workspace-ui/workspace-render.js +271 -0
  16. package/electron/workspace-ui/workspace-verbs.js +247 -0
  17. package/electron/workspace-ui/workspace.css +172 -0
  18. package/package.json +1 -1
  19. package/schemas/council-run-live.schema.json +57 -0
  20. package/schemas/council-run.schema.json +14 -0
  21. package/schemas/event.schema.json +15 -0
  22. package/schemas/progress.schema.json +37 -0
  23. package/schemas/run-live.schema.json +15 -0
  24. package/schemas/spend.schema.json +26 -1
  25. package/schemas/wave-live.schema.json +15 -0
  26. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  27. package/src/cli-handlers-council-run.js +86 -8
  28. package/src/cli-handlers-run.js +26 -0
  29. package/src/cli-handlers-spend.js +94 -32
  30. package/src/cli-handlers-watch.js +116 -0
  31. package/src/cli.js +58 -1
  32. package/src/council/briefings.js +35 -2
  33. package/src/council/run-budget.js +224 -0
  34. package/src/council/run-chair.js +10 -2
  35. package/src/council/run-debate.js +5 -1
  36. package/src/council/run-launch.js +58 -7
  37. package/src/council/run-stages.js +30 -3
  38. package/src/council/run.js +44 -15
  39. package/src/headless.js +356 -15
  40. package/src/mcp-council-awareness.js +98 -3
  41. package/src/mcp-council-run.js +28 -4
  42. package/src/mcp-notify.js +54 -0
  43. package/src/mcp-server.js +51 -1
  44. package/src/mcp-spend.js +125 -0
  45. package/src/mcp-tools.js +39 -0
  46. package/src/mcp-wait.js +28 -2
  47. package/src/observe/council-legs.js +183 -0
  48. package/src/observe/events.js +156 -0
  49. package/src/observe/follow.js +26 -0
  50. package/src/observe/live-doc.js +56 -0
  51. package/src/observe/on-complete.js +117 -0
  52. package/src/observe/watch-render.js +168 -0
  53. package/src/opencode-client.js +15 -3
  54. package/src/sidecar/child-sessions.js +198 -0
  55. package/src/sidecar/continue.js +32 -0
  56. package/src/sidecar/conversation-mirror.js +111 -37
  57. package/src/sidecar/fallback-chains.js +65 -0
  58. package/src/sidecar/fanout-budget.js +71 -0
  59. package/src/sidecar/fanout-leg-fallback.js +189 -0
  60. package/src/sidecar/fanout-leg.js +81 -27
  61. package/src/sidecar/fanout-retry.js +208 -0
  62. package/src/sidecar/fanout-validate.js +42 -4
  63. package/src/sidecar/fanout.js +54 -41
  64. package/src/sidecar/progress.js +5 -0
  65. package/src/sidecar/resume.js +12 -0
  66. package/src/sidecar/start.js +13 -1
  67. package/src/sidecar/tool-part.js +196 -0
  68. package/src/sidecar/workspace-window.js +62 -0
  69. package/src/spend-query.js +119 -0
  70. package/src/utils/env-num.js +42 -0
  71. package/src/utils/error-classify.js +31 -0
  72. package/src/utils/model-tiers.js +1 -1
  73. package/src/utils/path-fence.js +82 -0
  74. package/src/utils/pricing.js +98 -9
  75. package/src/utils/spend-ledger.js +24 -1
  76. package/src/workspace/artifact-guard.js +187 -0
  77. package/src/workspace/blind-mode.js +32 -0
  78. package/src/workspace/fold-format.js +95 -0
  79. package/src/workspace/live-normalize.js +156 -0
  80. package/src/workspace/matrix-model.js +94 -0
  81. package/src/workspace/run-detail.js +223 -0
  82. package/src/workspace/run-scan.js +148 -0
@@ -0,0 +1,168 @@
1
+ // src/observe/watch-render.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/watch-render
6
+ * Pure renderers + the poll loop for `amicus watch` (spec 5.1). Renderers are
7
+ * pure functions over the composed live doc (wave-progress.js precedent) so the
8
+ * table adds no testing burden beyond string assertions. The loop reads only
9
+ * the data layer: handlers.amicus_status (Surface C) each interval + the events
10
+ * tail (Surface B) for milestone lines. TTY -> in-place refresh table (ANSI
11
+ * erase-line + cursor-up, NO alternate screen — scrollback preserved);
12
+ * non-TTY/--plain -> milestone log lines; --json -> NDJSON.
13
+ */
14
+
15
+ const { formatCost } = require('../utils/pricing');
16
+ // Single source of truth for "what statuses are terminal" across the
17
+ // observability layer (live-doc.js's markLive uses the SAME set to decide
18
+ // when to stamp view:'live') — redefining it here would risk the two
19
+ // modules drifting, which would make this loop spin forever on a run
20
+ // live-doc already considers finished (or exit early on one still running).
21
+ const { TERMINAL } = require('./live-doc');
22
+ // Single source of truth for the wave status -> exit code mapping (same
23
+ // drift risk as TERMINAL above) — mapExitCode's non-passthrough branch
24
+ // delegates here instead of hand-rolling the complete/partial/else mapping.
25
+ const { waveExitCode } = require('../utils/result-schema');
26
+
27
+ const DASH = '—';
28
+ const legCost = (leg) => (leg.usage && leg.usage.cost ? formatCost(leg.usage.cost) : DASH);
29
+ const legTokens = (leg) => (leg.usage && leg.usage.tokens ? `${leg.usage.tokens.input || 0}/${leg.usage.tokens.output || 0}` : DASH);
30
+ const truncate = (s, n) => { const t = String(s || ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
31
+
32
+ const STAGE_MARK = { complete: '✓', running: '▶', pending: '·' };
33
+
34
+ /** The in-place refresh block for a composed wave/council/solo doc. */
35
+ function renderTable(doc, width = 100) {
36
+ const cost = doc.usage && doc.usage.cost ? formatCost(doc.usage.cost) : DASH;
37
+ const head = `${doc.taskId || doc.runId} ${doc.status} ${doc.elapsed || ''} ` +
38
+ (typeof doc.legsTotal === 'number' ? `legs ${doc.legsComplete}/${doc.legsTotal} ` : '') +
39
+ `cost ${cost}`;
40
+ const lines = [head];
41
+ if (Array.isArray(doc.stages)) { // council stage checklist
42
+ lines.push(doc.stages.map((s) => `${STAGE_MARK[s.status] || STAGE_MARK.pending} ${s.name}`).join(' '));
43
+ }
44
+ for (const leg of (doc.legs || [])) {
45
+ const flag = leg.stalled ? ' ⏳stalled' : '';
46
+ lines.push(
47
+ ` ${String(leg.model || leg.taskId).padEnd(26)} ${String(leg.phase || leg.status).padEnd(11)} ` +
48
+ `${String(leg.messages || 0).toString().padStart(3)}msg ${legTokens(leg).padStart(11)} ${legCost(leg).padStart(9)} | ` +
49
+ `${truncate(leg.latestPreview, Math.max(10, width - 70))}${flag}`
50
+ );
51
+ }
52
+ return lines.join('\n');
53
+ }
54
+
55
+ /** Non-TTY / --plain milestone lines + a periodic one-line rollup. */
56
+ function renderPlainLines(events, doc) {
57
+ const lines = (events || []).map((e) => {
58
+ switch (e.event) {
59
+ case 'wave-started': return `[wave-started] ${e.id} models=${(e.models || []).join(',')}`;
60
+ case 'leg-started': return `[leg-started] ${e.legId} ${e.model}`;
61
+ case 'leg-fallback': return `[leg-fallback] ${e.legId} ${e.fromModel} -> ${e.toModel} (${e.reason})`;
62
+ case 'leg-terminal': return `[leg-terminal] ${e.legId} ${e.model} ${e.status}`;
63
+ case 'wave-terminal': return `[wave-terminal] ${e.id} ${e.status} exit=${e.exitCode}`;
64
+ case 'run-started': return `[run-started] ${e.id} bench=${(e.bench || []).join(',')}`;
65
+ case 'stage-started': return `[stage-started] ${e.stage}`;
66
+ case 'stage-terminal': return `[stage-terminal] ${e.stage} ${e.status}`;
67
+ case 'run-terminal': return `[run-terminal] ${e.id} ${e.status} exit=${e.exitCode}`;
68
+ default: return `[${e.event}] ${e.id || ''}`;
69
+ }
70
+ });
71
+ if (doc) {
72
+ const cost = doc.usage && doc.usage.cost ? formatCost(doc.usage.cost) : DASH;
73
+ lines.push(`… ${doc.status} ${typeof doc.legsTotal === 'number' ? `${doc.legsComplete}/${doc.legsTotal} legs ` : ''}cost ${cost}`);
74
+ }
75
+ return lines;
76
+ }
77
+
78
+ /** Exit mapping (spec 5.1). Council passes through its recorded exitCode. */
79
+ function mapExitCode(doc) {
80
+ if (doc && typeof doc.exitCode === 'number') { return doc.exitCode; }
81
+ if (!doc) { return 1; }
82
+ return waveExitCode(doc.status);
83
+ }
84
+
85
+ /** Stable-stringify diff: emit the composed doc only when it changed. */
86
+ function emitJsonChange(doc, prevText) {
87
+ const text = JSON.stringify(doc);
88
+ return text === prevText ? { emit: false } : { emit: true, text };
89
+ }
90
+
91
+ /**
92
+ * The watch poll loop. DI-injected clock/status/tail for testability.
93
+ * @returns {Promise<number>} exit code
94
+ */
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
+ }
115
+ const intervalSec = Math.max(0.5, Number(args.interval) || 2);
116
+ const statusFn = deps.statusFn || ((id, p) => require('../mcp-server').handlers.amicus_status({ taskId: id }, p));
117
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
118
+ const isTTY = deps.isTTY !== undefined ? deps.isTTY : process.stdout.isTTY;
119
+ const { createEventTail, EVENTS_FILE } = require('./events');
120
+ const { getSessionDir } = require('../session-manager');
121
+ const path = require('path');
122
+ const eventsFile = target.kind === 'council'
123
+ ? path.join(target.runDir, EVENTS_FILE)
124
+ : path.join(getSessionDir(project, target.id), EVENTS_FILE);
125
+ const tail = createEventTail(eventsFile);
126
+ let prevJson = null;
127
+ let prevRollup = null;
128
+ let lastLineCount = 0;
129
+
130
+ for (;;) {
131
+ const res = await statusFn(target.id, project);
132
+ let doc;
133
+ try { doc = JSON.parse(res.content[0].text); } catch { doc = null; }
134
+ const events = tail.poll();
135
+ const isTerminal = doc && TERMINAL.has(doc.status);
136
+ if (args.json) {
137
+ for (const e of events) { process.stdout.write(JSON.stringify(e) + '\n'); }
138
+ if (doc) { const c = emitJsonChange(doc, prevJson); if (c.emit) { process.stdout.write(c.text + '\n'); prevJson = c.text; } }
139
+ } else if (isTTY && !args.plain) {
140
+ if (lastLineCount) { process.stdout.write(`\x1b[${lastLineCount}A\x1b[0J`); }
141
+ const block = renderTable(doc || { status: 'unknown', legs: [] }, process.stdout.columns || 100);
142
+ process.stdout.write(block + '\n');
143
+ lastLineCount = block.split('\n').length;
144
+ } else {
145
+ // Milestone event lines: the tail only yields new events, so these are
146
+ // always fresh — print every tick, unthrottled.
147
+ for (const line of renderPlainLines(events, null)) { process.stdout.write(line + '\n'); }
148
+ // Rollup line: change-only (mirrors the --json path above), so a
149
+ // multi-minute --plain watch doesn't spam an identical line every
150
+ // interval. Always printed on the terminal tick so the final state
151
+ // is never silently swallowed.
152
+ if (doc) {
153
+ const rollup = renderPlainLines([], doc)[0];
154
+ if (rollup !== prevRollup || isTerminal) {
155
+ process.stdout.write(rollup + '\n');
156
+ prevRollup = rollup;
157
+ }
158
+ }
159
+ }
160
+ if (isTerminal) {
161
+ if (args.json) { process.stdout.write(JSON.stringify(doc) + '\n'); }
162
+ return mapExitCode(doc);
163
+ }
164
+ await sleep(intervalSec * 1000);
165
+ }
166
+ }
167
+
168
+ module.exports = { renderTable, renderPlainLines, mapExitCode, emitJsonChange, runWatchLoop, DASH };
@@ -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 };
@@ -116,6 +116,26 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
116
116
  return sessionDir;
117
117
  }
118
118
 
119
+ /**
120
+ * Resolve a reopened session's usage, write it onto metadata, and append one
121
+ * attributed ledger row. Mirrors start.js's finalize (the only sites that
122
+ * dropped usage - BACKLOG.md:280). Best-effort ledger append; never throws.
123
+ * @returns {{usage: object|null}}
124
+ */
125
+ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, project, metadata }, ctx = {}) {
126
+ const { resolveUsage } = require('../utils/pricing');
127
+ const usage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
128
+ if (usage) {
129
+ metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
130
+ try {
131
+ const { appendSpend } = require('../utils/spend-ledger');
132
+ const gateway = metadata.gateway || (String(model).startsWith('openrouter/') ? 'openrouter' : 'direct');
133
+ appendSpend({ taskId, model, mode, usage, op, status, project, gateway }, ctx);
134
+ } catch { /* best-effort */ }
135
+ }
136
+ return { usage };
137
+ }
138
+
119
139
  /**
120
140
  * Continue from a previous sidecar session - Spec Reference: §4.4, §8.5
121
141
  * @returns {Promise<number>} process exit code
@@ -246,6 +266,17 @@ async function continueSidecar(options) {
246
266
  } else {
247
267
  finalizeSession(sessionDir, summary, project, meta, { quietStdout: json, status: terminal.status });
248
268
  }
269
+ // v4.3: attribute continue spend (C9/E4). Reload meta, write usage + append a
270
+ // ledger row (status: statusFromResult, matching start.js — not terminal.status).
271
+ {
272
+ const { statusFromResult } = require('../utils/result-schema');
273
+ const reloaded = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
274
+ const { usage } = finalizeSpendForReopen({
275
+ taskId: newTaskId, model, mode: headless ? 'headless' : 'interactive',
276
+ op: 'continue', result, status: statusFromResult(result), project, metadata: reloaded,
277
+ });
278
+ if (usage) { writeFileAtomic(metaPath, JSON.stringify(reloaded, null, 2), { mode: 0o600 }); }
279
+ }
249
280
 
250
281
  if (json) {
251
282
  const { buildRunResult } = require('../utils/result-schema');
@@ -261,5 +292,6 @@ module.exports = {
261
292
  loadPreviousSession,
262
293
  buildContinuationContext,
263
294
  createContinueSessionMetadata,
295
+ finalizeSpendForReopen,
264
296
  continueSidecar
265
297
  };
@@ -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
- pendingToolCalls: new Map(), // id -> {id,name,firstSeenAt} tool_use with no tool_result yet (B53)
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
- * Unresolved tool calls: tool_use ids seen with no matching tool_result yet
38
- * (matched by `part.tool_use_id`). Used by the headless poll loop's stall
39
- * detector (B53) to fail fast on a wedged tool call instead of burning the
40
- * full timeout. Returns a fresh array each call; `state.pendingToolCalls`
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 getPendingToolCalls(state) {
46
- return Array.from(state.pendingToolCalls.values());
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
- if (msg.info.tokens || typeof msg.info.cost === 'number') {
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.type === 'tool_use' || part.type === 'tool') && !state.seenToolCallIds.has(part.id)) {
103
- const toolCall = { id: part.id, name: part.name, input: part.input };
104
- state.seenToolCallIds.add(part.id);
105
- state.toolCalls.push(toolCall);
106
- // Bound growth: keep the most recent N tool-call payloads (BL-4). Dedup is the
107
- // Set above, so dropping the oldest here never causes a re-append.
108
- if (state.toolCalls.length > MAX_TOOL_CALLS) { state.toolCalls.shift(); }
109
- appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
110
-
111
- // Track as pending until a matching tool_result arrives (B53 stall detector).
112
- // firstSeenAt is captured once here — never touched again for this id.
113
- state.pendingToolCalls.set(part.id, { id: part.id, name: part.name, firstSeenAt: now() });
114
-
115
- // Update progress on tool_use detection
116
- progressUpdates.push({
117
- stage: 'receiving',
118
- extra: {
119
- messagesReceived: state.toolCalls.length,
120
- latestTool: part.name || undefined,
121
- stageLabel: part.name ? `Calling tool: ${part.name}` : 'Executing tool call...',
122
- },
123
- });
124
- state.receivingReported = true;
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, getPendingToolCalls };
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 };