@yagni-app/code-staging 1.1.4-staging.1415.1 → 1.1.4-staging.1416.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -713,7 +713,7 @@ export const HELP_TEXT = [
713
713
  " yagni version Print the CLI version.",
714
714
  "",
715
715
  "Common agent flags (passed straight through):",
716
- " --model <tier> Model tier (fixed to advanced).",
716
+ " --model <tier> advanced (default) | standard | efficient | peak; /tier switches mid-session.",
717
717
  " --thinking <level> off | minimal | low | medium | high | xhigh | max",
718
718
  " --session <id> Open a specific session; --fork <id> branches one.",
719
719
  " --output-format <fmt> Output format: text (default), json, stream-json.",
@@ -174,7 +174,9 @@ export function makeAskAdvisorTool(opts) {
174
174
  // rides every partial update's `details.tasks` (painted by
175
175
  // renderSubagentResult), the plain-text summary rides `content` for
176
176
  // headless consumers, and the harness "Working…" line mirrors it.
177
- const progress = newTaskProgress("advisor", params.question, Date.now());
177
+ // A consult always runs on the advisor rung; naming it on the live line
178
+ // is the point of the escalation being visible at all.
179
+ const progress = newTaskProgress("advisor", params.question, Date.now(), ADVISOR_MODEL_TIER);
178
180
  const ui = ctx?.hasUI ? ctx.ui : undefined;
179
181
  let lastWorking;
180
182
  const emit = () => {
@@ -43,6 +43,41 @@ export interface CostAccumulator {
43
43
  snapshot(): CostSnapshot;
44
44
  reset(): void;
45
45
  }
46
+ /**
47
+ * Prompt-cache hit rate, as a whole percent, or null when nothing was sent.
48
+ *
49
+ * WHY THIS IS A PRODUCT SURFACE, not a debug stat: the pilot objection to
50
+ * managed routing was that a reroute costs a cache miss and a fresh cache
51
+ * write, which on a long task is the dominant cost. The honest answer to that
52
+ * is a measurement, not a promise. A session reading 90%+ settles the question
53
+ * in a way a stability guarantee never could, and a session reading badly is a
54
+ * routing bug worth finding.
55
+ *
56
+ * Denominator is every prompt token the session sent: cache reads + cache
57
+ * WRITES + uncached input. Writes belong in it because a write IS the miss
58
+ * being complained about — leaving them out would flatter exactly the case
59
+ * this number exists to expose.
60
+ *
61
+ * The three buckets are disjoint on both feeds. pi-ai's openai-completions
62
+ * parser computes `input = prompt_tokens - cached - writes` before the number
63
+ * ever reaches `turn_end` (so the local accumulator is already disjoint), and
64
+ * the backend prices `promptTokens`, `cacheReadTokens` and
65
+ * `cacheCreationTokens` additively at three separate rates
66
+ * (`costTracker.calculateWithPricing`, `rateCard.priceSellMillicents`), which
67
+ * is only coherent if they do not overlap. Double-counting here would
68
+ * understate the rate rather than overstate it, but the denominator is right
69
+ * as written.
70
+ *
71
+ * PURE. Non-finite fields resolve to null rather than "NaN%", matching {@link usd}'s
72
+ * posture toward an untyped network response. One finiteness check covers every
73
+ * field: a NaN or Infinity in any bucket poisons `total`, so the guard below
74
+ * catches it before the division.
75
+ */
76
+ export declare function cacheHitPercent(snap: {
77
+ input: number;
78
+ cacheRead: number;
79
+ cacheWrite: number;
80
+ }): number | null;
46
81
  /** A pure, session-scoped usage accumulator. */
47
82
  export declare function makeCostAccumulator(): CostAccumulator;
48
83
  /** Extract a normalized {@link UsageDelta} from a turn_end message, defensively. */
@@ -105,6 +140,20 @@ export interface SpendResponse {
105
140
  * this exact money formatting instead of duplicating it.
106
141
  */
107
142
  export declare const usd: (millicents: number) => string;
143
+ /**
144
+ * Fold a spend response's rows into the three prompt-token buckets
145
+ * {@link cacheHitPercent} reads. PURE.
146
+ *
147
+ * Rows arrive untyped from the network, so a missing or non-numeric field
148
+ * contributes 0 rather than poisoning the sum into NaN (which would render as
149
+ * no line at all, the same fail-soft end state, but by accident instead of on
150
+ * purpose).
151
+ */
152
+ export declare function spendTokenTotals(spend: SpendResponse): {
153
+ input: number;
154
+ cacheRead: number;
155
+ cacheWrite: number;
156
+ };
108
157
  /**
109
158
  * Render the server-authoritative /cost lines: PREFERRED over
110
159
  * {@link formatCostLine}'s local accumulator whenever the spend fetch succeeds
@@ -115,7 +164,8 @@ export declare const usd: (millicents: number) => string;
115
164
  * (joined with "\n").
116
165
  *
117
166
  * Line order: total spend, one line per rate tier (aggregated across callers,
118
- * sorted by spend descending), the savings line (only
167
+ * sorted by spend descending), the prompt-cache hit rate (whenever the session
168
+ * sent any prompt tokens at all, 0% included), the savings line (only
119
169
  * when a counterfactual total resolved), an incomplete-counterfactual note,
120
170
  * an unbilled note, a dropped-run-ids note (carry-over from the /cost
121
171
  * re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
@@ -25,6 +25,42 @@
25
25
  * whenever it is shown it is explicitly labeled "(local, driver only)" rather
26
26
  * than presented as the whole session's spend.
27
27
  */
28
+ /**
29
+ * Prompt-cache hit rate, as a whole percent, or null when nothing was sent.
30
+ *
31
+ * WHY THIS IS A PRODUCT SURFACE, not a debug stat: the pilot objection to
32
+ * managed routing was that a reroute costs a cache miss and a fresh cache
33
+ * write, which on a long task is the dominant cost. The honest answer to that
34
+ * is a measurement, not a promise. A session reading 90%+ settles the question
35
+ * in a way a stability guarantee never could, and a session reading badly is a
36
+ * routing bug worth finding.
37
+ *
38
+ * Denominator is every prompt token the session sent: cache reads + cache
39
+ * WRITES + uncached input. Writes belong in it because a write IS the miss
40
+ * being complained about — leaving them out would flatter exactly the case
41
+ * this number exists to expose.
42
+ *
43
+ * The three buckets are disjoint on both feeds. pi-ai's openai-completions
44
+ * parser computes `input = prompt_tokens - cached - writes` before the number
45
+ * ever reaches `turn_end` (so the local accumulator is already disjoint), and
46
+ * the backend prices `promptTokens`, `cacheReadTokens` and
47
+ * `cacheCreationTokens` additively at three separate rates
48
+ * (`costTracker.calculateWithPricing`, `rateCard.priceSellMillicents`), which
49
+ * is only coherent if they do not overlap. Double-counting here would
50
+ * understate the rate rather than overstate it, but the denominator is right
51
+ * as written.
52
+ *
53
+ * PURE. Non-finite fields resolve to null rather than "NaN%", matching {@link usd}'s
54
+ * posture toward an untyped network response. One finiteness check covers every
55
+ * field: a NaN or Infinity in any bucket poisons `total`, so the guard below
56
+ * catches it before the division.
57
+ */
58
+ export function cacheHitPercent(snap) {
59
+ const total = snap.input + snap.cacheRead + snap.cacheWrite;
60
+ if (!Number.isFinite(total) || total <= 0)
61
+ return null;
62
+ return Math.round((snap.cacheRead / total) * 100);
63
+ }
28
64
  /** A pure, session-scoped usage accumulator. */
29
65
  export function makeCostAccumulator() {
30
66
  let s = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
@@ -77,7 +113,11 @@ const fmt = (n) => n.toLocaleString("en-US");
77
113
  */
78
114
  export function formatCostLine(snap, headroom, advisorLine, source) {
79
115
  const turns = `${snap.turns} turn${snap.turns === 1 ? "" : "s"}`;
80
- const cached = snap.cacheRead > 0 ? ` (${fmt(snap.cacheRead)} cached)` : "";
116
+ // Gated on "did this session send anything", NOT on "did it read any cache".
117
+ // A cold session is precisely the case the hit rate exists to expose, so
118
+ // hiding the segment at 0 would suppress the only bad news it can carry.
119
+ const hit = cacheHitPercent(snap);
120
+ const cached = hit === null ? "" : ` (${fmt(snap.cacheRead)} cached, ${hit}% hit)`;
81
121
  const label = source ? `Session usage (${source})` : "Session usage";
82
122
  const base = `${label}: ${turns}, ${fmt(snap.input)} in / ${fmt(snap.output)} out tokens${cached}, ` +
83
123
  `$${snap.cost.toFixed(2)} this session.`;
@@ -102,6 +142,27 @@ export const usd = (millicents) => {
102
142
  return "0.00";
103
143
  return (millicents / 100_000).toFixed(2);
104
144
  };
145
+ /**
146
+ * Fold a spend response's rows into the three prompt-token buckets
147
+ * {@link cacheHitPercent} reads. PURE.
148
+ *
149
+ * Rows arrive untyped from the network, so a missing or non-numeric field
150
+ * contributes 0 rather than poisoning the sum into NaN (which would render as
151
+ * no line at all, the same fail-soft end state, but by accident instead of on
152
+ * purpose).
153
+ */
154
+ export function spendTokenTotals(spend) {
155
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
156
+ let input = 0;
157
+ let cacheRead = 0;
158
+ let cacheWrite = 0;
159
+ for (const row of spend.rows ?? []) {
160
+ input += num(row?.promptTokens);
161
+ cacheRead += num(row?.cacheReadTokens);
162
+ cacheWrite += num(row?.cacheCreationTokens);
163
+ }
164
+ return { input, cacheRead, cacheWrite };
165
+ }
105
166
  /**
106
167
  * Render the server-authoritative /cost lines: PREFERRED over
107
168
  * {@link formatCostLine}'s local accumulator whenever the spend fetch succeeds
@@ -112,7 +173,8 @@ export const usd = (millicents) => {
112
173
  * (joined with "\n").
113
174
  *
114
175
  * Line order: total spend, one line per rate tier (aggregated across callers,
115
- * sorted by spend descending), the savings line (only
176
+ * sorted by spend descending), the prompt-cache hit rate (whenever the session
177
+ * sent any prompt tokens at all, 0% included), the savings line (only
116
178
  * when a counterfactual total resolved), an incomplete-counterfactual note,
117
179
  * an unbilled note, a dropped-run-ids note (carry-over from the /cost
118
180
  * re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
@@ -134,6 +196,14 @@ export function formatServerCostLines(spend, headroom, droppedRunCount = 0) {
134
196
  const calls = `${fmt(agg.dispatches)} call${agg.dispatches === 1 ? "" : "s"}`;
135
197
  lines.push(` ${tier}: $${usd(agg.sellMillicents)} over ${calls}.`);
136
198
  }
199
+ // The whole-session cache hit rate, children included. This is the better
200
+ // of the two feeds for the question it answers: the local accumulator only
201
+ // ever sees driver turns, while the long, context-heavy work that makes
202
+ // cache continuity matter is exactly what runs in /go stages and subagents.
203
+ const hit = cacheHitPercent(spendTokenTotals(spend));
204
+ if (hit !== null) {
205
+ lines.push(`Prompt cache: ${hit}% of sent tokens served from cache.`);
206
+ }
137
207
  // typeof guard (not just !== null): a network response is untyped at
138
208
  // runtime, and a stray string/boolean here must never sneak "NN%" into the
139
209
  // rendered line.
@@ -32,7 +32,7 @@
32
32
  */
33
33
  export type SinkLevel = "error" | "warn" | "info" | "debug";
34
34
  export interface SinkEvent {
35
- /** Former filename / subsystem: tool, turn, guardian, image-paste, ask-question, cost, auth, cmux, hooks. */
35
+ /** Former filename / subsystem: tool, turn, guardian, image-paste, ask-question, cost, auth, cmux, hooks, tier. */
36
36
  source: string;
37
37
  level: SinkLevel;
38
38
  /** Stable machine name (e.g. "turn_start", "bash.exit_1", "denied"). */
@@ -214,7 +214,9 @@ export { makeDecisionCapture, CAPTURE_DEBOUNCE_MS } from "./decisionCapture.js";
214
214
  export type { DecisionCapture, DecisionCaptureDeps, BlessCaptureInfo, CaptureCtx } from "./decisionCapture.js";
215
215
  export { registerAmbientRecall, fetchRecall, formatRecallBlock, toRepoRelativePath, normalizeRecall, RECALL_TIMEOUT_MS, RECALL_MIN_DECISIONS, } from "./recall.js";
216
216
  export type { RecallResult, RecallClientOpts, RegisterRecallDeps } from "./recall.js";
217
- export { makeCostAccumulator, formatCostLine, registerCostCommand } from "./costHud.js";
217
+ export { makeCostAccumulator, formatCostLine, registerCostCommand, cacheHitPercent, spendTokenTotals, } from "./costHud.js";
218
+ export { SELECTABLE_TIERS, SESSION_START_TIER, formatStartCorrection, formatStartCorrectionFailed, formatTierStatus, formatTierSwitchFailed, formatTierSwitched, isSelectableTier, orderDriverCatalog, parseTierArg, registerStartTierGuard, registerTierCommand, startTierCorrection, } from "./tierCommand.js";
219
+ export type { SelectableTier, TierRequest } from "./tierCommand.js";
218
220
  export type { CostSnapshot, Headroom } from "./costHud.js";
219
221
  export { makeTokenProvider, makeAuthedFetch, persistRotationToProfile, PROACTIVE_REFRESH_WINDOW_MS, REFRESH_RETRY_MS, } from "./tokenProvider.js";
220
222
  export type { TokenProvider, TokenProviderDeps, ScheduleFn } from "./tokenProvider.js";
@@ -61,6 +61,7 @@ import { flushSpool as defaultFlushSpool } from "./spool.js";
61
61
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
62
62
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
63
63
  import { buildYagniProvider } from "./provider.js";
64
+ import { orderDriverCatalog, registerStartTierGuard, registerTierCommand } from "./tierCommand.js";
64
65
  import { registerChipEditor } from "./chipEditor.js";
65
66
  import { registerSlashCommandFilter } from "./slashCommandFilter.js";
66
67
  import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
@@ -290,9 +291,16 @@ export async function registerYagni(pi, deps = {}) {
290
291
  fields: { reason: !userGrounding ? "user_config" : "workspace" },
291
292
  });
292
293
  }
293
- // Lock the interactive session to the `advanced` tier only. The backend
294
- // catalog returns all tiers, but only `advanced` is registered with the
295
- // `yagni` provider, so /model and Ctrl+P show a single entry.
294
+ // The interactive session's picker: the four concrete rungs, in the order
295
+ // `tierCommand.ts` defines (advanced first, peak last). The backend catalog
296
+ // also returns `balanced`, which is a session routing POLICY rather than a
297
+ // rung, so it is dropped here — five entries would read as five tiers.
298
+ //
299
+ // This used to be an `advanced`-only filter, i.e. a single-entry picker and
300
+ // a fixed rung. Pilot feedback asked for a visible manual dial, so the rung
301
+ // is now the developer's to move; `registerStartTierGuard` keeps every
302
+ // session OPENING on `advanced` and never on `peak` (see tierCommand.ts for
303
+ // why peak is reachable but never a resting state).
296
304
  //
297
305
  // Two exemptions:
298
306
  // - CHILD processes: the runner stamps YAGNI_CALLER on every /go, subagent,
@@ -302,13 +310,14 @@ export async function registerYagni(pi, deps = {}) {
302
310
  // catalog — the advanced-only filter used to leave those tier names
303
311
  // unresolved, so pi fell back to its custom-model-id path (a stderr
304
312
  // warning on every non-advanced child and default-shaped capability
305
- // metadata instead of the real context-window/token caps).
313
+ // metadata instead of the real context-window/token caps). A child also
314
+ // must keep resolving `balanced`, which the driver ordering drops.
306
315
  // - Eval mode: a headless harness (scoping sessions, code evals) names its
307
316
  // tier explicitly — the backend's scoping sessions run `--model standard`
308
317
  // — and there is no /model picker to keep tidy. Filtering there turns a
309
318
  // valid tier request into "no models match".
310
319
  const driver = isDriverCaller(env);
311
- const catalog = evalMode || !driver ? fullCatalog : fullCatalog.filter((m) => m.id === "advanced");
320
+ const catalog = evalMode || !driver ? fullCatalog : orderDriverCatalog(fullCatalog);
312
321
  // YAG-471: the driver's own completions carry attribution headers read from
313
322
  // this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
314
323
  // defaults to "driver" when unset, i.e. every session that is not a /go
@@ -1165,6 +1174,15 @@ export async function registerYagni(pi, deps = {}) {
1165
1174
  // count as costHud's "Excludes N earlier /go runs." note.
1166
1175
  droppedSessionRuns,
1167
1176
  });
1177
+ // The manual tier dial. `/model` (pi's own picker) now lists the four rungs
1178
+ // because the driver catalog carries them; `/tier` is the tier-language
1179
+ // sibling that reports and switches inline. The start guard is what keeps
1180
+ // "movable" from becoming "parked on peak" — driver sessions only, since a
1181
+ // child or eval lane names its tier on the command line.
1182
+ if (!evalMode && driver) {
1183
+ registerTierCommand(pi);
1184
+ registerStartTierGuard(pi);
1185
+ }
1168
1186
  // YAG-471: surface the backend's vision reroute header as a quiet notice
1169
1187
  // (see rerouteNotice.ts for the event/header contract). One notifier per
1170
1188
  // session dedupes on the from->to pair so a long session does not repeat
@@ -1898,7 +1916,8 @@ export { makeDecisionCapture, CAPTURE_DEBOUNCE_MS } from "./decisionCapture.js";
1898
1916
  // M1: ambient judgment recall appended to `read` results (pure cores + fetch).
1899
1917
  export { registerAmbientRecall, fetchRecall, formatRecallBlock, toRepoRelativePath, normalizeRecall, RECALL_TIMEOUT_MS, RECALL_MIN_DECISIONS, } from "./recall.js";
1900
1918
  // P2: the session cost accumulator + /cost command.
1901
- export { makeCostAccumulator, formatCostLine, registerCostCommand } from "./costHud.js";
1919
+ export { makeCostAccumulator, formatCostLine, registerCostCommand, cacheHitPercent, spendTokenTotals, } from "./costHud.js";
1920
+ export { SELECTABLE_TIERS, SESSION_START_TIER, formatStartCorrection, formatStartCorrectionFailed, formatTierStatus, formatTierSwitchFailed, formatTierSwitched, isSelectableTier, orderDriverCatalog, parseTierArg, registerStartTierGuard, registerTierCommand, startTierCorrection, } from "./tierCommand.js";
1902
1921
  // W4 trust plumbing: the refreshing TokenProvider (kills the boot snapshot) and
1903
1922
  // the ONE 401 refresh-then-retry-once fetch seam every tool shares.
1904
1923
  export { makeTokenProvider, makeAuthedFetch, persistRotationToProfile, PROACTIVE_REFRESH_WINDOW_MS, REFRESH_RETRY_MS, } from "./tokenProvider.js";
@@ -17,7 +17,7 @@
17
17
  * the bare title bar), so everything here must stay boringly total.
18
18
  */
19
19
  import { type Component } from "@earendil-works/pi-tui";
20
- import type { JsonEvent, StageResult, StageUsage } from "./pipeline/types.js";
20
+ import type { JsonEvent, ModelTier, StageResult, StageUsage } from "./pipeline/types.js";
21
21
  /** The minimal slice of pi's `Theme` the renderers style with (same shape as FeedTheme). */
22
22
  export interface RenderTheme {
23
23
  bold(text: string): string;
@@ -35,6 +35,21 @@ export interface SubagentActionEntry {
35
35
  export interface SubagentTaskProgress {
36
36
  agent: string;
37
37
  task: string;
38
+ /**
39
+ * The rung this child runs on, shown on both the live line and the receipt.
40
+ *
41
+ * This is the visible half of managed routing. A fan-out that quietly puts
42
+ * wide search on `efficient` and mechanical edits on `standard` is doing
43
+ * exactly the cost work the tiers exist for, and until it is printed the
44
+ * developer has to take that on faith. Seeded from the agent definition at
45
+ * spawn (so it shows while the child is still running) and re-stamped from
46
+ * the child's own `StageResult` on completion, which is what reflects a
47
+ * `YAGNI_GO_TIER_CAP` clamp.
48
+ *
49
+ * Optional: a caller-injected runStage seam (unit tests, a custom lane)
50
+ * need not report one, and the renderers simply omit the segment.
51
+ */
52
+ tier?: ModelTier;
38
53
  status: "running" | "done" | "error";
39
54
  /** -1 while the child is still running (mirrors the pi subagent example). */
40
55
  exitCode: number;
@@ -58,7 +73,7 @@ export interface SubagentDetails {
58
73
  }
59
74
  /** Bound on the retained action log so a chatty child cannot grow details unbounded. */
60
75
  export declare const ACTION_LOG_MAX = 120;
61
- export declare function newTaskProgress(agent: string, task: string, startedAt: number): SubagentTaskProgress;
76
+ export declare function newTaskProgress(agent: string, task: string, startedAt: number, tier?: ModelTier): SubagentTaskProgress;
62
77
  /**
63
78
  * Fold one child NDJSON event into the task's progress. Returns true when the
64
79
  * record changed (the tool emits an update), false for events we drop.
@@ -75,7 +90,7 @@ export declare function formatDuration(ms: number): string;
75
90
  * over the churning current-action line with elapsed time and live tokens.
76
91
  */
77
92
  export declare function runningLines(p: SubagentTaskProgress, theme: RenderTheme, now: number, frame: string): string[];
78
- /** One-line completion receipt: `✓ agent · N tool uses · Xk tokens · Ys · $c`. */
93
+ /** One-line completion receipt: `✓ agent · tier · N tool uses · Xk tokens · Ys · $c`. */
79
94
  export declare function receiptLine(p: SubagentTaskProgress, theme: RenderTheme): string;
80
95
  /**
81
96
  * Plain-text (no theme) summary for the partial result's `content`, so headless
@@ -31,10 +31,11 @@ const TASK_PREVIEW_MAX = 64;
31
31
  /** Spinner cadence; matches the /go feed's SPINNER_TICK_MS. */
32
32
  const SPINNER_TICK_MS = 120;
33
33
  const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
34
- export function newTaskProgress(agent, task, startedAt) {
34
+ export function newTaskProgress(agent, task, startedAt, tier) {
35
35
  return {
36
36
  agent,
37
37
  task,
38
+ ...(tier ? { tier } : {}),
38
39
  status: "running",
39
40
  exitCode: -1,
40
41
  startedAt,
@@ -122,6 +123,12 @@ export function finalizeTask(p, result, endedAt) {
122
123
  // An aborted child can close with exit 0 (SIGTERM close reports a null code),
123
124
  // so the stop reason outranks the exit code for the status glyph.
124
125
  p.status = result.exitCode === 0 && result.stopReason !== "aborted" ? "done" : "error";
126
+ // The tier the child ACTUALLY ran on outranks the one seeded at spawn: a
127
+ // capped lane (`YAGNI_GO_TIER_CAP`) clamps the rung after the fact, and the
128
+ // receipt must show what was billed, not what was asked for. Keep the seed
129
+ // when the seam reported none.
130
+ if (result.tier)
131
+ p.tier = result.tier;
125
132
  p.exitCode = result.exitCode;
126
133
  p.endedAt = endedAt;
127
134
  p.usage = { ...result.usage };
@@ -190,15 +197,20 @@ function liveDetailText(p) {
190
197
  export function runningLines(p, theme, now, frame) {
191
198
  const title = `${frame} ${theme.fg("accent", p.agent)} — ${theme.fg("dim", clip(p.task, TASK_PREVIEW_MAX))}`;
192
199
  let detail = ` ${theme.fg("muted", "↳")} ${theme.fg("toolOutput", liveDetailText(p))}`;
200
+ if (p.tier)
201
+ detail += theme.fg("dim", ` · ${p.tier}`);
193
202
  detail += theme.fg("dim", ` · ${formatDuration(now - p.startedAt)}`);
194
203
  const tokens = taskTokens(p);
195
204
  if (tokens > 0)
196
205
  detail += theme.fg("dim", ` · ${formatTokens(tokens)} tokens`);
197
206
  return [title, detail];
198
207
  }
199
- /** One-line completion receipt: `✓ agent · N tool uses · Xk tokens · Ys · $c`. */
208
+ /** One-line completion receipt: `✓ agent · tier · N tool uses · Xk tokens · Ys · $c`. */
200
209
  export function receiptLine(p, theme) {
201
210
  const parts = [
211
+ // Tier leads the dim run: it is the thing a developer is scanning for when
212
+ // they want to know the fan-out went out cheap.
213
+ ...(p.tier ? [p.tier] : []),
202
214
  countToolUses(p.toolCalls),
203
215
  `${formatTokens(taskTokens(p))} tokens`,
204
216
  formatDuration((p.endedAt ?? p.startedAt) - p.startedAt),
@@ -601,7 +601,10 @@ export function makeSubagentTool(deps = {}) {
601
601
  // `details` (rendered by renderSubagentResult), the plain-text summary
602
602
  // rides `content` (pi's fallback renderer and headless consumers), and
603
603
  // the harness "Working…" line mirrors the aggregate while children run.
604
- const progresses = resolved.map(({ def, task }) => newTaskProgress(def.name, task, Date.now()));
604
+ // `def.model` is the agent's declared rung, so the live line names the
605
+ // tier from the moment the child spins up rather than only on the
606
+ // receipt (finalizeTask re-stamps it from the child's own result).
607
+ const progresses = resolved.map(({ def, task }) => newTaskProgress(def.name, task, Date.now(), def.model));
605
608
  const ui = ctx?.hasUI ? ctx.ui : undefined;
606
609
  let lastWorking;
607
610
  const emit = () => {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `/tier` — the session's rung, named and switchable.
3
+ *
4
+ * Background. The driver session used to register exactly one model with the
5
+ * `yagni` provider (`advanced`), so pi's built-in `/model` picker and Ctrl+P
6
+ * showed a single entry and the rung was effectively fixed. Pilot feedback
7
+ * (the "managed vs. manual" thread) was not really a request for specific
8
+ * model ids: it was a request for a VISIBLE, manual dial and for the tier in
9
+ * play to be legible at a glance. So the driver catalog now carries the four
10
+ * concrete rungs and this module adds the tier-language command on top of
11
+ * pi's model picker.
12
+ *
13
+ * Two deliberate constraints, both encoded here rather than left to the
14
+ * launcher:
15
+ *
16
+ * - **A session DEFAULTS to `advanced`.** The launcher injects `--model
17
+ * advanced` when the user named no model (launch.ts). That is the whole
18
+ * of the default: a session that explicitly asks for another rung gets
19
+ * it, since `--model standard` (the backend's scoping sessions) is a
20
+ * legitimate request and not something to override.
21
+ * - **A session never STARTS on `peak`**, whatever asked for it.
22
+ * {@link startTierCorrection} is the belt-and-braces half the launcher
23
+ * flag cannot reach: a resume, a persisted picker choice, or an explicit
24
+ * `--model peak` all open on `advanced` instead. Peak is the deliberate
25
+ * escape hatch, not a place to park: it is the thinnest-margin rung and
26
+ * the one a session left sitting on would quietly bill several times
27
+ * over. It stays reachable in one keystroke (`/tier peak`) and is never
28
+ * the resting state. `/advise` remains the capped, per-question
29
+ * escalation for people who want peak judgment without moving the whole
30
+ * session.
31
+ *
32
+ * `balanced` is deliberately NOT selectable. It names a session routing
33
+ * POLICY rather than a rung (see `pipeline/tierCap.ts` and `mapModelTier` in
34
+ * subagents.ts, which exclude it for the same reason), and offering a policy
35
+ * alongside four rungs in one picker makes the list read as five tiers.
36
+ *
37
+ * Pure helpers first, wiring last — the same split as `permission.ts` and
38
+ * `advisor.ts`, so the rules are testable without a pi session.
39
+ *
40
+ * LOGGING. This module writes `source: "tier"` to the error sink, with three
41
+ * events: `start_tier_corrected` (info) and `start_tier_correction_failed`
42
+ * (warn) from the session_start guard, and `tier_switch_failed` (warn) from
43
+ * the command. All three are TRAIL-ONLY: nothing downstream parses them, and
44
+ * they exist so `/diagnostics` and a support session can answer "why is this
45
+ * session on peak" after the fact. A rung that failed to move is invisible in
46
+ * every other record, which is why the failures log at all.
47
+ */
48
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
49
+ /** The provider the tier models are registered under (see provider.ts). */
50
+ export declare const YAGNI_PROVIDER = "yagni";
51
+ /**
52
+ * The rungs a driver session may sit on, in PICKER ORDER.
53
+ *
54
+ * Order is the product statement, not an accident: the default leads, the two
55
+ * cheaper rungs follow, and `peak` sits last. The backend catalog's own order
56
+ * is strongest-first (`balanced`, `peak`, `advanced`, ...), which would put
57
+ * peak in the first, most-clickable position of a picker it should never be
58
+ * the resting state of.
59
+ */
60
+ export declare const SELECTABLE_TIERS: readonly ["advanced", "standard", "efficient", "peak"];
61
+ export type SelectableTier = (typeof SELECTABLE_TIERS)[number];
62
+ /** The rung every interactive driver session opens on. */
63
+ export declare const SESSION_START_TIER: SelectableTier;
64
+ export declare function isSelectableTier(value: string | undefined): value is SelectableTier;
65
+ /**
66
+ * The driver's picker catalog: the four rungs, in {@link SELECTABLE_TIERS}
67
+ * order, filtered against what the backend actually served.
68
+ *
69
+ * Filtering against the live catalog rather than returning SELECTABLE_TIERS
70
+ * verbatim matters: a workspace whose catalog omits a rung (an entitlement,
71
+ * a backend that dropped one) must not get a picker entry that 404s on first
72
+ * use. Anything the backend serves that is not a selectable rung (`balanced`)
73
+ * is dropped.
74
+ */
75
+ export declare function orderDriverCatalog<T extends {
76
+ id: string;
77
+ }>(models: readonly T[]): T[];
78
+ /**
79
+ * The rung a starting session must be moved to, or null to leave it alone.
80
+ *
81
+ * Returns the start tier when the session would otherwise open on a
82
+ * never-start rung. A session opening on any other selectable rung is left
83
+ * as-is: an explicit `--model standard` (the backend's scoping sessions) is a
84
+ * legitimate choice, and only `peak` is the one we refuse to rest on.
85
+ *
86
+ * PURE. Callers decide whether the correction applies to them at all —
87
+ * children and eval lanes name their tier on the command line and are exempt.
88
+ */
89
+ export declare function startTierCorrection(currentId: string | undefined): SelectableTier | null;
90
+ /** What `/tier <args>` was asking for. PURE. */
91
+ export type TierRequest = {
92
+ kind: "show";
93
+ } | {
94
+ kind: "set";
95
+ tier: SelectableTier;
96
+ } | {
97
+ kind: "unknown";
98
+ input: string;
99
+ };
100
+ export declare function parseTierArg(args: string): TierRequest;
101
+ /** The `/tier` status line: what is in play, and what else is reachable. PURE. */
102
+ export declare function formatTierStatus(currentId: string | undefined): string;
103
+ /** The notice shown when a switch lands. PURE. */
104
+ export declare function formatTierSwitched(tier: SelectableTier): string;
105
+ /** Upper bound on a trail `reason`, in characters. */
106
+ export declare const TRAIL_REASON_MAX = 180;
107
+ /**
108
+ * Turn a caught value into a trail-safe `reason`. PURE.
109
+ *
110
+ * A provider's `err.message` is untrusted text on its way to a DURABLE file
111
+ * that a developer may later paste into a support thread, so it is scrubbed
112
+ * and bounded at write time rather than trusted to be short and clean.
113
+ *
114
+ * Redaction delegates to {@link scrubSecrets}, the pattern set already used
115
+ * for captured command output: it covers provider key prefixes (sk-, gh*_,
116
+ * xox*, AKIA, AIza), connection-string credentials, secret-named keys,
117
+ * base64/JWT blobs, and opaque bearer tokens. An earlier version of this
118
+ * function hand-rolled a keyword-anchored regex, which missed a bare
119
+ * `Bearer sk-live-…` with no preceding key name, missed a bare `key=` (it
120
+ * only matched `api_key`), and would have drifted from the shared set over
121
+ * time. Order matters: redaction runs BEFORE truncation, so a secret cannot
122
+ * survive by sitting past the cut.
123
+ *
124
+ * Then made readable, then bounded. A streamed provider error arrives with
125
+ * whatever the terminal put in it (ANSI colour and cursor codes survive a
126
+ * whitespace collapse and garble the entry, or move the cursor of whatever
127
+ * later cats the log) and can carry a whole response body; the sink rotates
128
+ * at 256KB, so an unbounded message evicts the surrounding context that makes
129
+ * it readable.
130
+ *
131
+ * A non-Error resolves to "threw": there is nothing to quote, and inventing
132
+ * a String(err) of an arbitrary thrown value is how "[object Object]" ends up
133
+ * in a log.
134
+ */
135
+ export declare function trailReason(err: unknown): string;
136
+ /** The notice shown when a requested switch did not land. PURE. */
137
+ export declare function formatTierSwitchFailed(requested: SelectableTier, currentId: string | undefined): string;
138
+ /** The notice shown when a session is corrected off a never-start rung. PURE. */
139
+ export declare function formatStartCorrection(from: string, to: SelectableTier): string;
140
+ /**
141
+ * Register `/tier`.
142
+ *
143
+ * pi already ships `/model` (and Ctrl+P), which now lists the four rungs
144
+ * because the driver catalog carries them — that surface is left entirely to
145
+ * pi rather than shadowed with a same-named command. `/tier` is the
146
+ * tier-language sibling: it reports the current rung inline without opening a
147
+ * picker, and switches in one line when the rung is already known.
148
+ *
149
+ * `setModel` resolves through `ctx.modelRegistry.find`, so a rung the
150
+ * workspace's catalog never registered fails with a real message instead of
151
+ * silently selecting nothing.
152
+ */
153
+ export declare function registerTierCommand(pi: ExtensionAPI): void;
154
+ /**
155
+ * The notice shown when the correction could not be applied. PURE.
156
+ *
157
+ * Two sentences on purpose, and in this order. The failure is stated first
158
+ * without a cause, because the cause varies (a registry miss, a refusal, a
159
+ * thrown provider call) and none of them is about pricing; then the standing
160
+ * fact about the rung, which is true whatever went wrong and is the reason
161
+ * the developer should care at all. The earlier single-sentence phrasing
162
+ * fused the two and read as though pricing had caused the failure.
163
+ */
164
+ export declare function formatStartCorrectionFailed(from: string): string;
165
+ /**
166
+ * Hold an interactive driver to {@link SESSION_START_TIER} at session start.
167
+ *
168
+ * Wired on `session_start` so it sees the model the session actually resolved
169
+ * (flag, persisted choice, or resume) rather than the one the launcher asked
170
+ * for. Fail-soft in the same spirit as the rest of the extension's
171
+ * session_start work: a registry miss, a rejected `setModel` or a thrown
172
+ * handler leaves the session on whatever it opened with rather than taking
173
+ * the session down.
174
+ *
175
+ * Fail-soft is NOT fail-silent. The whole point of the guard is that nobody
176
+ * drives a long session on the thinnest-margin rung by accident, so a failed
177
+ * correction is the one case that must not pass unremarked: every exit path
178
+ * logs (with the rung it could not move off), and the user is told plainly
179
+ * that they are on peak and how to leave. A silent failure here would bill
180
+ * exactly like the bug the guard exists to prevent.
181
+ */
182
+ export declare function registerStartTierGuard(pi: ExtensionAPI): void;
183
+ //# sourceMappingURL=tierCommand.d.ts.map
@@ -0,0 +1,381 @@
1
+ /**
2
+ * `/tier` — the session's rung, named and switchable.
3
+ *
4
+ * Background. The driver session used to register exactly one model with the
5
+ * `yagni` provider (`advanced`), so pi's built-in `/model` picker and Ctrl+P
6
+ * showed a single entry and the rung was effectively fixed. Pilot feedback
7
+ * (the "managed vs. manual" thread) was not really a request for specific
8
+ * model ids: it was a request for a VISIBLE, manual dial and for the tier in
9
+ * play to be legible at a glance. So the driver catalog now carries the four
10
+ * concrete rungs and this module adds the tier-language command on top of
11
+ * pi's model picker.
12
+ *
13
+ * Two deliberate constraints, both encoded here rather than left to the
14
+ * launcher:
15
+ *
16
+ * - **A session DEFAULTS to `advanced`.** The launcher injects `--model
17
+ * advanced` when the user named no model (launch.ts). That is the whole
18
+ * of the default: a session that explicitly asks for another rung gets
19
+ * it, since `--model standard` (the backend's scoping sessions) is a
20
+ * legitimate request and not something to override.
21
+ * - **A session never STARTS on `peak`**, whatever asked for it.
22
+ * {@link startTierCorrection} is the belt-and-braces half the launcher
23
+ * flag cannot reach: a resume, a persisted picker choice, or an explicit
24
+ * `--model peak` all open on `advanced` instead. Peak is the deliberate
25
+ * escape hatch, not a place to park: it is the thinnest-margin rung and
26
+ * the one a session left sitting on would quietly bill several times
27
+ * over. It stays reachable in one keystroke (`/tier peak`) and is never
28
+ * the resting state. `/advise` remains the capped, per-question
29
+ * escalation for people who want peak judgment without moving the whole
30
+ * session.
31
+ *
32
+ * `balanced` is deliberately NOT selectable. It names a session routing
33
+ * POLICY rather than a rung (see `pipeline/tierCap.ts` and `mapModelTier` in
34
+ * subagents.ts, which exclude it for the same reason), and offering a policy
35
+ * alongside four rungs in one picker makes the list read as five tiers.
36
+ *
37
+ * Pure helpers first, wiring last — the same split as `permission.ts` and
38
+ * `advisor.ts`, so the rules are testable without a pi session.
39
+ *
40
+ * LOGGING. This module writes `source: "tier"` to the error sink, with three
41
+ * events: `start_tier_corrected` (info) and `start_tier_correction_failed`
42
+ * (warn) from the session_start guard, and `tier_switch_failed` (warn) from
43
+ * the command. All three are TRAIL-ONLY: nothing downstream parses them, and
44
+ * they exist so `/diagnostics` and a support session can answer "why is this
45
+ * session on peak" after the fact. A rung that failed to move is invisible in
46
+ * every other record, which is why the failures log at all.
47
+ */
48
+ import { logEvent } from "./errorSink.js";
49
+ import { scrubSecrets } from "./pipeline/scrubSecrets.js";
50
+ /** The provider the tier models are registered under (see provider.ts). */
51
+ export const YAGNI_PROVIDER = "yagni";
52
+ /**
53
+ * The rungs a driver session may sit on, in PICKER ORDER.
54
+ *
55
+ * Order is the product statement, not an accident: the default leads, the two
56
+ * cheaper rungs follow, and `peak` sits last. The backend catalog's own order
57
+ * is strongest-first (`balanced`, `peak`, `advanced`, ...), which would put
58
+ * peak in the first, most-clickable position of a picker it should never be
59
+ * the resting state of.
60
+ */
61
+ export const SELECTABLE_TIERS = ["advanced", "standard", "efficient", "peak"];
62
+ /** The rung every interactive driver session opens on. */
63
+ export const SESSION_START_TIER = "advanced";
64
+ /** Rungs a session must never be left parked on at start. Peak only, today. */
65
+ const NEVER_START_TIERS = new Set(["peak"]);
66
+ /** One-line description per rung, for `/tier` with no argument. */
67
+ const TIER_BLURB = {
68
+ advanced: "the default driver rung",
69
+ standard: "cheaper, for mechanical work",
70
+ efficient: "cheapest, for search and triage",
71
+ peak: "strongest judgment, several times the spend",
72
+ };
73
+ export function isSelectableTier(value) {
74
+ return !!value && SELECTABLE_TIERS.includes(value);
75
+ }
76
+ /**
77
+ * The driver's picker catalog: the four rungs, in {@link SELECTABLE_TIERS}
78
+ * order, filtered against what the backend actually served.
79
+ *
80
+ * Filtering against the live catalog rather than returning SELECTABLE_TIERS
81
+ * verbatim matters: a workspace whose catalog omits a rung (an entitlement,
82
+ * a backend that dropped one) must not get a picker entry that 404s on first
83
+ * use. Anything the backend serves that is not a selectable rung (`balanced`)
84
+ * is dropped.
85
+ */
86
+ export function orderDriverCatalog(models) {
87
+ const byId = new Map(models.map((m) => [m.id, m]));
88
+ const ordered = [];
89
+ for (const tier of SELECTABLE_TIERS) {
90
+ const model = byId.get(tier);
91
+ if (model)
92
+ ordered.push(model);
93
+ }
94
+ return ordered;
95
+ }
96
+ /**
97
+ * The rung a starting session must be moved to, or null to leave it alone.
98
+ *
99
+ * Returns the start tier when the session would otherwise open on a
100
+ * never-start rung. A session opening on any other selectable rung is left
101
+ * as-is: an explicit `--model standard` (the backend's scoping sessions) is a
102
+ * legitimate choice, and only `peak` is the one we refuse to rest on.
103
+ *
104
+ * PURE. Callers decide whether the correction applies to them at all —
105
+ * children and eval lanes name their tier on the command line and are exempt.
106
+ */
107
+ export function startTierCorrection(currentId) {
108
+ if (!currentId)
109
+ return null;
110
+ return NEVER_START_TIERS.has(currentId) ? SESSION_START_TIER : null;
111
+ }
112
+ export function parseTierArg(args) {
113
+ const raw = args.trim().toLowerCase();
114
+ if (!raw)
115
+ return { kind: "show" };
116
+ if (isSelectableTier(raw))
117
+ return { kind: "set", tier: raw };
118
+ return { kind: "unknown", input: raw };
119
+ }
120
+ /** The `/tier` status line: what is in play, and what else is reachable. PURE. */
121
+ export function formatTierStatus(currentId) {
122
+ const current = isSelectableTier(currentId) ? currentId : undefined;
123
+ const head = current
124
+ ? `This session is on ${current} (${TIER_BLURB[current]}).`
125
+ : `This session is on "${currentId ?? "unknown"}", which is not one of the selectable rungs.`;
126
+ const options = SELECTABLE_TIERS.map((t) => ` /tier ${t} - ${TIER_BLURB[t]}`).join("\n");
127
+ return `${head}\nSwitch with /tier <rung>, or pick from /model:\n${options}\nEvery session starts on ${SESSION_START_TIER}.`;
128
+ }
129
+ /** The notice shown when a switch lands. PURE. */
130
+ export function formatTierSwitched(tier) {
131
+ const parked = tier === "peak"
132
+ ? " Peak is priced for judgment, not for driving: the next session starts back on advanced."
133
+ : "";
134
+ return `Session tier is now ${tier} (${TIER_BLURB[tier]}).${parked}`;
135
+ }
136
+ /** Upper bound on a trail `reason`, in characters. */
137
+ export const TRAIL_REASON_MAX = 180;
138
+ /**
139
+ * A signed URL's query string, the one credential shape {@link scrubSecrets}
140
+ * does not carry a rule for: the signature sits under an arbitrary parameter
141
+ * name (`sig`, `X-Amz-Signature`, `se`) and is usually too short for the
142
+ * base64 rule, so no secret-named-key or provider-prefix pattern reaches it.
143
+ *
144
+ * Kept local rather than added to `scrubSecrets`, deliberately: that module is
145
+ * mirrored verbatim against the backend's copy and asks to be kept in sync, so
146
+ * a one-off addition here would fork it. If signed URLs start showing up in
147
+ * trails for real, the rule belongs in BOTH copies rather than this file.
148
+ */
149
+ const SIGNED_URL_QUERY = /(https?:\/\/[^\s?]+)\?\S*/gi;
150
+ /**
151
+ * ANSI CSI escape sequences. A provider error relayed from a streamed
152
+ * terminal can carry colour and cursor codes, which survive a whitespace
153
+ * collapse and render the durable trail entry unreadable (or move the cursor
154
+ * of whatever later cats the log).
155
+ *
156
+ * Stripped BEFORE any redaction runs, never after: see {@link trailReason}.
157
+ */
158
+ // eslint-disable-next-line no-control-regex
159
+ const ANSI_ESCAPE = /\x1b\[[0-9;]*[A-Za-z]/g;
160
+ /**
161
+ * Turn a caught value into a trail-safe `reason`. PURE.
162
+ *
163
+ * A provider's `err.message` is untrusted text on its way to a DURABLE file
164
+ * that a developer may later paste into a support thread, so it is scrubbed
165
+ * and bounded at write time rather than trusted to be short and clean.
166
+ *
167
+ * Redaction delegates to {@link scrubSecrets}, the pattern set already used
168
+ * for captured command output: it covers provider key prefixes (sk-, gh*_,
169
+ * xox*, AKIA, AIza), connection-string credentials, secret-named keys,
170
+ * base64/JWT blobs, and opaque bearer tokens. An earlier version of this
171
+ * function hand-rolled a keyword-anchored regex, which missed a bare
172
+ * `Bearer sk-live-…` with no preceding key name, missed a bare `key=` (it
173
+ * only matched `api_key`), and would have drifted from the shared set over
174
+ * time. Order matters: redaction runs BEFORE truncation, so a secret cannot
175
+ * survive by sitting past the cut.
176
+ *
177
+ * Then made readable, then bounded. A streamed provider error arrives with
178
+ * whatever the terminal put in it (ANSI colour and cursor codes survive a
179
+ * whitespace collapse and garble the entry, or move the cursor of whatever
180
+ * later cats the log) and can carry a whole response body; the sink rotates
181
+ * at 256KB, so an unbounded message evicts the surrounding context that makes
182
+ * it readable.
183
+ *
184
+ * A non-Error resolves to "threw": there is nothing to quote, and inventing
185
+ * a String(err) of an arbitrary thrown value is how "[object Object]" ends up
186
+ * in a log.
187
+ */
188
+ export function trailReason(err) {
189
+ if (!(err instanceof Error) || !err.message)
190
+ return "threw";
191
+ // ANSI comes off FIRST, before anything tries to match a pattern. Every
192
+ // redaction rule here keys on a contiguous run (`sk-[A-Za-z0-9]{16,}`, a
193
+ // URL up to its `?`), so an escape sequence sitting inside a credential
194
+ // splits that run and the rule misses. Stripping afterwards would then
195
+ // reassemble the full secret into the durable trail, having skipped the
196
+ // redaction that was supposed to catch it. Normalize, then match.
197
+ const normalized = err.message.replace(ANSI_ESCAPE, "");
198
+ const scrubbed = scrubSecrets(normalized)
199
+ .replace(SIGNED_URL_QUERY, "$1?[REDACTED]")
200
+ .replace(/\s+/g, " ")
201
+ .trim();
202
+ if (!scrubbed)
203
+ return "threw";
204
+ return scrubbed.length > TRAIL_REASON_MAX
205
+ ? `${scrubbed.slice(0, TRAIL_REASON_MAX - 1)}…`
206
+ : scrubbed;
207
+ }
208
+ /** The notice shown when a requested switch did not land. PURE. */
209
+ export function formatTierSwitchFailed(requested, currentId) {
210
+ const still = currentId ? ` Still on ${currentId}.` : "";
211
+ return `Could not switch to ${requested}.${still}`;
212
+ }
213
+ /** The notice shown when a session is corrected off a never-start rung. PURE. */
214
+ export function formatStartCorrection(from, to) {
215
+ return `Opened on ${to} rather than ${from}: sessions always start on ${to}. Use /tier ${from} to go back.`;
216
+ }
217
+ /**
218
+ * Register `/tier`.
219
+ *
220
+ * pi already ships `/model` (and Ctrl+P), which now lists the four rungs
221
+ * because the driver catalog carries them — that surface is left entirely to
222
+ * pi rather than shadowed with a same-named command. `/tier` is the
223
+ * tier-language sibling: it reports the current rung inline without opening a
224
+ * picker, and switches in one line when the rung is already known.
225
+ *
226
+ * `setModel` resolves through `ctx.modelRegistry.find`, so a rung the
227
+ * workspace's catalog never registered fails with a real message instead of
228
+ * silently selecting nothing.
229
+ */
230
+ export function registerTierCommand(pi) {
231
+ pi.registerCommand("tier", {
232
+ description: "Show or switch this session's model tier (advanced / standard / efficient / peak).",
233
+ handler: async (args, ctx) => {
234
+ const notify = (message, type) => {
235
+ if (ctx.hasUI)
236
+ ctx.ui.notify(message, type);
237
+ };
238
+ const request = parseTierArg(args);
239
+ if (request.kind === "show") {
240
+ notify(formatTierStatus(ctx.model?.id), "info");
241
+ return;
242
+ }
243
+ if (request.kind === "unknown") {
244
+ notify(`"${request.input}" is not a tier. Choose one of: ${SELECTABLE_TIERS.join(", ")}.`, "warning");
245
+ return;
246
+ }
247
+ if (ctx.model?.id === request.tier) {
248
+ notify(`Already on ${request.tier}.`, "info");
249
+ return;
250
+ }
251
+ const model = ctx.modelRegistry.find(YAGNI_PROVIDER, request.tier);
252
+ if (!model) {
253
+ logEvent({
254
+ source: "tier",
255
+ level: "warn",
256
+ event: "tier_switch_failed",
257
+ fields: { from: ctx.model?.id ?? "unknown", to: request.tier, reason: "registry_miss" },
258
+ });
259
+ notify(`The ${request.tier} tier is not available on this workspace.`, "error");
260
+ return;
261
+ }
262
+ // Mirrors the start guard's posture: a switch that does not land is
263
+ // logged AND named. `setModel` reaches a provider, so it can reject or
264
+ // throw; an uncaught throw here would escape the command handler, and a
265
+ // silent one would leave a developer believing they moved rungs when
266
+ // they did not. Both end on the same durable trail entry.
267
+ let ok = false;
268
+ let reason = "set_model_rejected";
269
+ try {
270
+ ok = await pi.setModel(model);
271
+ }
272
+ catch (err) {
273
+ reason = trailReason(err);
274
+ }
275
+ if (!ok) {
276
+ logEvent({
277
+ source: "tier",
278
+ level: "warn",
279
+ event: "tier_switch_failed",
280
+ fields: { from: ctx.model?.id ?? "unknown", to: request.tier, reason },
281
+ });
282
+ }
283
+ // A failed switch still has to leave the rung in play legible: that is
284
+ // the whole feature, and "could not switch" alone tells you nothing
285
+ // about what you are now driving on.
286
+ notify(ok ? formatTierSwitched(request.tier) : formatTierSwitchFailed(request.tier, ctx.model?.id), ok ? "info" : "error");
287
+ },
288
+ });
289
+ }
290
+ /**
291
+ * The notice shown when the correction could not be applied. PURE.
292
+ *
293
+ * Two sentences on purpose, and in this order. The failure is stated first
294
+ * without a cause, because the cause varies (a registry miss, a refusal, a
295
+ * thrown provider call) and none of them is about pricing; then the standing
296
+ * fact about the rung, which is true whatever went wrong and is the reason
297
+ * the developer should care at all. The earlier single-sentence phrasing
298
+ * fused the two and read as though pricing had caused the failure.
299
+ */
300
+ export function formatStartCorrectionFailed(from) {
301
+ return `Could not move this session off ${from}. That rung is priced for judgment rather than driving, so use /tier ${SESSION_START_TIER} unless you need it.`;
302
+ }
303
+ /**
304
+ * Hold an interactive driver to {@link SESSION_START_TIER} at session start.
305
+ *
306
+ * Wired on `session_start` so it sees the model the session actually resolved
307
+ * (flag, persisted choice, or resume) rather than the one the launcher asked
308
+ * for. Fail-soft in the same spirit as the rest of the extension's
309
+ * session_start work: a registry miss, a rejected `setModel` or a thrown
310
+ * handler leaves the session on whatever it opened with rather than taking
311
+ * the session down.
312
+ *
313
+ * Fail-soft is NOT fail-silent. The whole point of the guard is that nobody
314
+ * drives a long session on the thinnest-margin rung by accident, so a failed
315
+ * correction is the one case that must not pass unremarked: every exit path
316
+ * logs (with the rung it could not move off), and the user is told plainly
317
+ * that they are on peak and how to leave. A silent failure here would bill
318
+ * exactly like the bug the guard exists to prevent.
319
+ */
320
+ export function registerStartTierGuard(pi) {
321
+ pi.on("session_start", async (_event, ctx) => {
322
+ const from = ctx.model?.id ?? "unknown";
323
+ // Self-contained: this runs from the outer catch as well as from the
324
+ // ordinary failure paths, so a throw out of `notify` (a UI teardown
325
+ // mid-session_start) must not escape the handler it was called to
326
+ // rescue. `logEvent` already swallows its own errors; the notify does
327
+ // not, so it gets its own guard and the log still lands either way.
328
+ const warn = (reason) => {
329
+ logEvent({
330
+ source: "tier",
331
+ level: "warn",
332
+ event: "start_tier_correction_failed",
333
+ fields: { from, to: SESSION_START_TIER, reason },
334
+ });
335
+ try {
336
+ if (ctx.hasUI)
337
+ ctx.ui.notify(formatStartCorrectionFailed(from), "warning");
338
+ }
339
+ catch {
340
+ /* the trail entry above is the durable half; a dead UI cannot undo it */
341
+ }
342
+ };
343
+ try {
344
+ const correction = startTierCorrection(ctx.model?.id);
345
+ if (!correction)
346
+ return;
347
+ const model = ctx.modelRegistry.find(YAGNI_PROVIDER, correction);
348
+ if (!model) {
349
+ warn("registry_miss");
350
+ return;
351
+ }
352
+ if (!(await pi.setModel(model))) {
353
+ warn("set_model_rejected");
354
+ return;
355
+ }
356
+ logEvent({
357
+ source: "tier",
358
+ level: "info",
359
+ event: "start_tier_corrected",
360
+ fields: { from, to: correction },
361
+ });
362
+ // Guarded separately from the failure notify, and for a sharper reason:
363
+ // the correction has ALREADY landed by this point, so letting a throw
364
+ // here reach the outer catch would write `start_tier_correction_failed`
365
+ // for a session that did move rungs. A trail that contradicts the
366
+ // session's actual state is worse than no trail at all, which is the
367
+ // whole thing this logging exists to avoid.
368
+ try {
369
+ if (ctx.hasUI)
370
+ ctx.ui.notify(formatStartCorrection(from, correction), "info");
371
+ }
372
+ catch {
373
+ /* the rung moved and the info entry says so; a dead UI changes neither */
374
+ }
375
+ }
376
+ catch (err) {
377
+ warn(trailReason(err));
378
+ }
379
+ });
380
+ }
381
+ //# sourceMappingURL=tierCommand.js.map
package/dist/launch.js CHANGED
@@ -127,10 +127,13 @@ export function buildLaunch(creds, passthroughArgs, opts) {
127
127
  // Always load our extension. Default the provider to `yagni` unless the user
128
128
  // explicitly chose one (so power users can still point pi elsewhere).
129
129
  const userChoseProvider = passthroughArgs.some(arg => arg === "--provider" || arg.startsWith("--provider="));
130
- // Default the model to the `advanced` tier. The model is locked: the
131
- // catalog is filtered to only `advanced` (see index.ts), so the user
132
- // cannot switch to a different tier via /model or Ctrl+P. The proxy still
133
- // resolves the tier to the concrete backing model.
130
+ // Open every session on the `advanced` tier. The rung is no longer locked:
131
+ // the driver catalog carries all four (see index.ts and tierCommand.ts), so
132
+ // /model, Ctrl+P and /tier can move it mid-session. This flag decides only
133
+ // where a session STARTS, and the extension's start guard enforces the same
134
+ // thing for the paths this flag cannot reach (a resume, a persisted picker
135
+ // choice, an explicit `--model peak`). The proxy still resolves the tier to
136
+ // the concrete backing model.
134
137
  const userChoseModel = passthroughArgs.some(arg => arg === "--model" || arg.startsWith("--model="));
135
138
  // Engineering-practice enrichment (YAG-496): appended to the DRIVER's system
136
139
  // prompt only — /go stage children and subagents build their own pi argv, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.4-staging.1415.1",
3
+ "version": "1.1.4-staging.1416.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "f4fe956b10a09436d8429ae3f6b82cfa73a8d880"
61
+ "yagniSourceSha": "f17aa2f61573701f8a9b62da1d99ca022b96a7bd"
62
62
  }