@yagni-app/code-staging 0.2.1-staging.1027.1 → 0.2.1-staging.1032.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
@@ -161,7 +161,7 @@ export const HELP_TEXT = [
161
161
  " yagni version Print the CLI version.",
162
162
  "",
163
163
  "Common agent flags (passed straight through):",
164
- " --model <tier> Pick the model tier (balanced by default).",
164
+ " --model <tier> Model tier (fixed to advanced).",
165
165
  " --thinking <level> off | minimal | low | medium | high | xhigh | max",
166
166
  " --session <id> Open a specific session; --fork <id> branches one.",
167
167
  " --mode json Emit machine-readable events (for scripts and CI).",
@@ -22,8 +22,8 @@
22
22
  * State is held in a closure via {@link makeAdvisorState}; no module-level
23
23
  * mutable state, so two sessions in one process cannot bleed into each other.
24
24
  */
25
- /** The session tier that may escalate. Balanced only, by design. */
26
- export declare const ADVISOR_TIER = "balanced";
25
+ /** The session tier that may escalate. Advanced only, by design. */
26
+ export declare const ADVISOR_TIER = "advanced";
27
27
  /** The tier a consult itself runs on. */
28
28
  export declare const ADVISOR_MODEL_TIER = "peak";
29
29
  /** Bounds on escalation within a single session. */
@@ -64,8 +64,8 @@ export interface ConsultGateInput {
64
64
  * The tier check reads the model at CALL time rather than at registration time:
65
65
  * tools register once at activation but pi's picker can switch the session model
66
66
  * afterwards, so a registration-time check would both leave the tool live after
67
- * switching away from Balanced and hide it forever from a session that switched
68
- * to Balanced.
67
+ * switching away from Advanced and hide it forever from a session that switched
68
+ * to Advanced.
69
69
  */
70
70
  export declare function decideConsult(input: ConsultGateInput): ConsultDecision;
71
71
  /**
@@ -22,8 +22,8 @@
22
22
  * State is held in a closure via {@link makeAdvisorState}; no module-level
23
23
  * mutable state, so two sessions in one process cannot bleed into each other.
24
24
  */
25
- /** The session tier that may escalate. Balanced only, by design. */
26
- export const ADVISOR_TIER = "balanced";
25
+ /** The session tier that may escalate. Advanced only, by design. */
26
+ export const ADVISOR_TIER = "advanced";
27
27
  /** The tier a consult itself runs on. */
28
28
  export const ADVISOR_MODEL_TIER = "peak";
29
29
  export const DEFAULT_ADVISOR_LIMITS = {
@@ -49,17 +49,16 @@ export function makeAdvisorState() {
49
49
  * The tier check reads the model at CALL time rather than at registration time:
50
50
  * tools register once at activation but pi's picker can switch the session model
51
51
  * afterwards, so a registration-time check would both leave the tool live after
52
- * switching away from Balanced and hide it forever from a session that switched
53
- * to Balanced.
52
+ * switching away from Advanced and hide it forever from a session that switched
53
+ * to Advanced.
54
54
  */
55
55
  export function decideConsult(input) {
56
56
  const { model, state, limits } = input;
57
57
  if (model !== ADVISOR_TIER) {
58
58
  return {
59
59
  allow: false,
60
- reason: `ask_advisor is only available on the Balanced tier (this session is on ` +
61
- `"${model ?? "unknown"}"). Switch the model to Balanced to consult the ` +
62
- `peak-tier advisor, or reason it through on the current tier.`,
60
+ reason: `ask_advisor is only available on the Advanced tier (this session is on ` +
61
+ `"${model ?? "unknown"}").`,
63
62
  };
64
63
  }
65
64
  if (state.consults >= limits.maxConsults) {
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `ask_advisor` — the peak-tier escalation available to Balanced sessions (YAG-380).
2
+ * `ask_advisor` — the peak-tier escalation available to Advanced sessions (YAG-380).
3
3
  *
4
- * Cheap driver, expensive consultant: a Balanced session drives on `advanced` and
4
+ * Cheap driver, expensive consultant: an Advanced session drives on `advanced` and
5
5
  * calls this when it hits a judgment call worth the strongest model. The consult
6
6
  * spawns a child pi at `peak` through the same `runStage` seam `/go` uses, and
7
7
  * returns the advice as plain TEXT the driver acts on — text we own, so
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `ask_advisor` — the peak-tier escalation available to Balanced sessions (YAG-380).
2
+ * `ask_advisor` — the peak-tier escalation available to Advanced sessions (YAG-380).
3
3
  *
4
- * Cheap driver, expensive consultant: a Balanced session drives on `advanced` and
4
+ * Cheap driver, expensive consultant: an Advanced session drives on `advanced` and
5
5
  * calls this when it hits a judgment call worth the strongest model. The consult
6
6
  * spawns a child pi at `peak` through the same `runStage` seam `/go` uses, and
7
7
  * returns the advice as plain TEXT the driver acts on — text we own, so
@@ -79,7 +79,7 @@ export function makeAskAdvisorTool(opts) {
79
79
  label: "Ask the advisor",
80
80
  description: "Escalate ONE hard judgment call to the peak-tier advisor — the strongest " +
81
81
  "model available, which reads the code itself and returns a recommendation. " +
82
- "Available only on the Balanced tier, and capped per session, so use it for " +
82
+ "Available only on the Advanced tier, and capped per session, so use it for " +
83
83
  "calls that are genuinely worth it: an architectural fork with no obvious " +
84
84
  "right answer, a subtle correctness question you cannot settle by reading, " +
85
85
  "a change whose blast radius you are unsure of, or a second opinion before " +
@@ -87,7 +87,7 @@ export function makeAskAdvisorTool(opts) {
87
87
  "lookups (use ask_yagni), for anything you can settle by reading the code, " +
88
88
  "or to review work you have already finished. Pass a sharp question plus the " +
89
89
  "relevant excerpts — never a conversation transcript.",
90
- promptSnippet: "ask_advisor: escalate one hard judgment call to the peak-tier advisor (Balanced sessions, capped).",
90
+ promptSnippet: "ask_advisor: escalate one hard judgment call to the peak-tier advisor (Advanced sessions, capped).",
91
91
  promptGuidelines: [
92
92
  "Call ask_advisor only for a genuine judgment fork — an architectural choice, a subtle correctness question, or a second opinion before an approach you would have to unwind. Reading the code is cheaper; do that first.",
93
93
  "Ask ONE specific question per consult, and include the excerpts that matter. The advisor reads the repo itself, so point it at the right place rather than pasting everything.",
@@ -159,7 +159,7 @@ export function makeAskAdvisorTool(opts) {
159
159
  */
160
160
  export function registerAdviseCommand(pi, tool) {
161
161
  pi.registerCommand("advise", {
162
- description: "Escalate one hard call to the peak-tier advisor (Balanced sessions only, capped per session).",
162
+ description: "Escalate one hard call to the peak-tier advisor (Advanced sessions only, capped per session).",
163
163
  handler: async (args, ctx) => {
164
164
  const notify = (message, type) => {
165
165
  if (ctx.hasUI)
@@ -129,8 +129,6 @@ export function attributionHeaders(env = process.env) {
129
129
  headers["x-yagni-run-id"] = runId;
130
130
  const caller = env.YAGNI_CALLER ?? "";
131
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
132
  return headers;
135
133
  }
136
134
  /**
@@ -52,7 +52,20 @@ export interface Headroom {
52
52
  remaining: number;
53
53
  unit: string;
54
54
  }
55
- export declare function formatCostLine(snap: CostSnapshot, headroom?: Headroom | null, advisorLine?: string, source?: string, boosted?: boolean): string;
55
+ /**
56
+ * Render the /cost line. Pure, no em-dashes. `headroom` null means it was
57
+ * unavailable. `advisorLine` is the ask_advisor subtotal (empty when the session
58
+ * never escalated). `source`, when given, is parenthesized right after "Session
59
+ * usage", used to explicitly label this as the LOCAL, driver-only fallback when
60
+ * the server-authoritative spend fetch is unavailable (see
61
+ * {@link formatServerCostLines}, the preferred path).
62
+ *
63
+ * HONESTY NOTE: this counter can only see the DRIVER session, because it is fed
64
+ * by pi's `turn_end`. Work that runs in a child process — every /go stage, and
65
+ * every advisor consult — never emits a parent `turn_end`, so it is invisible
66
+ * here. The advisor subtotal is threaded in explicitly for exactly that reason.
67
+ */
68
+ export declare function formatCostLine(snap: CostSnapshot, headroom?: Headroom | null, advisorLine?: string, source?: string): string;
56
69
  /**
57
70
  * One row of `GET /api/yagni-code/spend`'s per-caller x rate-tier breakdown.
58
71
  * Mirrored locally rather than importing `@yagni/shared`, since this extension
@@ -102,25 +115,15 @@ export declare const usd: (millicents: number) => string;
102
115
  * (joined with "\n").
103
116
  *
104
117
  * 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
118
+ * sorted by spend descending), the savings line (only
107
119
  * when a counterfactual total resolved), an incomplete-counterfactual note,
108
120
  * an unbilled note, a dropped-run-ids note (carry-over from the /cost
109
121
  * re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
110
122
  *
111
123
  * `droppedRunCount` defaults to 0 (no note) so every existing direct caller
112
124
  * of this pure function keeps behaving identically without passing it.
113
- *
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.
122
125
  */
123
- export declare function formatServerCostLines(spend: SpendResponse, headroom?: Headroom | null, droppedRunCount?: number, boosted?: boolean): string;
126
+ export declare function formatServerCostLines(spend: SpendResponse, headroom?: Headroom | null, droppedRunCount?: number): string;
124
127
  export interface RegisterCostDeps {
125
128
  /** Fetch remaining credit headroom; return null when unavailable. Fail-soft. */
126
129
  fetchHeadroom?: (signal?: AbortSignal) => Promise<Headroom | null>;
@@ -132,15 +135,6 @@ export interface RegisterCostDeps {
132
135
  * ordinary caller row.
133
136
  */
134
137
  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
138
  /**
145
139
  * Fetch the server-authoritative session spend (YAG-383). Absent, throwing,
146
140
  * or resolving null all fall back to the local `turn_end` accumulator, with
@@ -75,21 +75,18 @@ const fmt = (n) => n.toLocaleString("en-US");
75
75
  * every advisor consult — never emits a parent `turn_end`, so it is invisible
76
76
  * here. The advisor subtotal is threaded in explicitly for exactly that reason.
77
77
  */
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) {
78
+ export function formatCostLine(snap, headroom, advisorLine, source) {
81
79
  const turns = `${snap.turns} turn${snap.turns === 1 ? "" : "s"}`;
82
80
  const cached = snap.cacheRead > 0 ? ` (${fmt(snap.cacheRead)} cached)` : "";
83
81
  const label = source ? `Session usage (${source})` : "Session usage";
84
82
  const base = `${label}: ${turns}, ${fmt(snap.input)} in / ${fmt(snap.output)} out tokens${cached}, ` +
85
83
  `$${snap.cost.toFixed(2)} this session.`;
86
84
  const advisor = advisorLine?.trim() ? ` ${advisorLine.trim()}` : "";
87
- const boost = boosted ? ` ${BOOST_LINE}` : "";
88
85
  if (headroom)
89
- return `${base}${advisor}${boost} Credit headroom: ${headroom.remaining} ${headroom.unit}.`;
86
+ return `${base}${advisor} Credit headroom: ${headroom.remaining} ${headroom.unit}.`;
90
87
  if (headroom === null)
91
- return `${base}${advisor}${boost} Credit headroom unavailable right now.`;
92
- return `${base}${advisor}${boost}`;
88
+ return `${base}${advisor} Credit headroom unavailable right now.`;
89
+ return `${base}${advisor}`;
93
90
  }
94
91
  /**
95
92
  * Millicents (1/1000 of a cent) -> dollars, 2 decimals. Guards against a
@@ -115,25 +112,15 @@ export const usd = (millicents) => {
115
112
  * (joined with "\n").
116
113
  *
117
114
  * 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
115
+ * sorted by spend descending), the savings line (only
120
116
  * when a counterfactual total resolved), an incomplete-counterfactual note,
121
117
  * an unbilled note, a dropped-run-ids note (carry-over from the /cost
122
118
  * re-review — see sessionRuns.ts's `droppedSessionRuns`), then headroom.
123
119
  *
124
120
  * `droppedRunCount` defaults to 0 (no note) so every existing direct caller
125
121
  * 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
122
  */
136
- export function formatServerCostLines(spend, headroom, droppedRunCount = 0, boosted = false) {
123
+ export function formatServerCostLines(spend, headroom, droppedRunCount = 0) {
137
124
  const lines = [`Session spend: $${usd(spend.totalSellMillicents)} (server).`];
138
125
  const tierTotals = new Map();
139
126
  for (const row of spend.rows) {
@@ -147,23 +134,6 @@ export function formatServerCostLines(spend, headroom, droppedRunCount = 0, boos
147
134
  const calls = `${fmt(agg.dispatches)} call${agg.dispatches === 1 ? "" : "s"}`;
148
135
  lines.push(` ${tier}: $${usd(agg.sellMillicents)} over ${calls}.`);
149
136
  }
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
137
  // typeof guard (not just !== null): a network response is untyped at
168
138
  // runtime, and a stray string/boolean here must never sneak "NN%" into the
169
139
  // rendered line.
@@ -233,15 +203,6 @@ export function registerCostCommand(pi, deps = {}) {
233
203
  : Promise.resolve(null),
234
204
  ]);
235
205
  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
206
  // Quiet divergence check: both totals must be available, and it never
246
207
  // affects what the user sees. Scoped to `caller === "driver"` rows only
247
208
  // (like-for-like with the local `turn_end` accumulator, which can only
@@ -276,7 +237,7 @@ export function registerCostCommand(pi, deps = {}) {
276
237
  catch {
277
238
  /* a diagnostic must never break /cost */
278
239
  }
279
- await pi.sendUserMessage(formatServerCostLines(spend, headroom, dropped, boosted));
240
+ await pi.sendUserMessage(formatServerCostLines(spend, headroom, dropped));
280
241
  return;
281
242
  }
282
243
  let advisorLine = "";
@@ -286,7 +247,7 @@ export function registerCostCommand(pi, deps = {}) {
286
247
  catch {
287
248
  /* usage accounting must never break /cost */
288
249
  }
289
- await pi.sendUserMessage(formatCostLine(localSnap, headroom, advisorLine, "local, driver only", boosted));
250
+ await pi.sendUserMessage(formatCostLine(localSnap, headroom, advisorLine, "local, driver only"));
290
251
  },
291
252
  });
292
253
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Custom footer for the YAGNI CLI — replaces pi's built-in footer.
3
+ *
4
+ * Renders three lines:
5
+ * 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
6
+ * 2. model · ↑in ↓out $cost · ctx% (session stats; context % is an integer)
7
+ * 3. extension statuses (branding, todo counter, mode) joined by " · "
8
+ *
9
+ * --- How to customize the status bar (for future tickets) ---
10
+ *
11
+ * LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
12
+ * The `pi` (ExtensionAPI) object passed to the factory has no UI methods —
13
+ * they live on `ctx.ui` (ExtensionUIContext), which is only bound after
14
+ * `_applyExtensionBindings` runs, immediately before the `session_start`
15
+ * event. So the footer must be set inside a `pi.on("session_start", ...)`
16
+ * handler, guarded by `ctx.mode === "tui"` (no footer in RPC/print mode).
17
+ * See index.ts's session_start handler for the wiring, and pi's
18
+ * `docs/extensions.md` § "Widgets, Status, and Footer" + the `custom-header.ts`
19
+ * example for the canonical pattern.
20
+ *
21
+ * FACTORY SIGNATURE: `setFooter((tui, theme, footerData) => Component)` where:
22
+ * - `tui` — the TUI instance (screen dimensions, focus)
23
+ * - `theme` — the current Theme (use `theme.fg("dim", text)` etc.)
24
+ * - `footerData` — ReadonlyFooterDataProvider: `getGitBranch()`,
25
+ * `getExtensionStatuses()` (statuses set via `ctx.ui.setStatus(key, text)`),
26
+ * `getAvailableProviderCount()`
27
+ * Model info, token stats, and context usage are NOT on `footerData` — they're
28
+ * on `ctx` (the ExtensionContext passed to the session_start handler):
29
+ * `ctx.model`, `ctx.sessionManager`, `ctx.getContextUsage()`. Thread them
30
+ * through a closure if the footer needs them (this module's
31
+ * `createYagniFooterFactory` does exactly that).
32
+ *
33
+ * COMPONENT CONTRACT: the returned object needs `render(width: number): string[]`
34
+ * (returns the lines to display, one string per row) and `invalidate()` (called
35
+ * when the component should re-render). Optionally `dispose()` for cleanup.
36
+ *
37
+ * REFERENCE: pi's built-in footer lives at
38
+ * `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js`
39
+ * — useful reference for what data to replicate and how to format it.
40
+ */
41
+ import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
+ export declare const BRANCH_MAX_WIDTH = 60;
43
+ /** Format token counts for compact footer display (mirrors pi's formatTokens). */
44
+ export declare function formatTokens(count: number): string;
45
+ /** Shorten cwd relative to home, like pi's built-in footer. */
46
+ export declare function formatCwd(cwd: string, home: string | undefined): string;
47
+ interface UsageTotals {
48
+ input: number;
49
+ output: number;
50
+ cacheRead: number;
51
+ cacheWrite: number;
52
+ cost: number;
53
+ }
54
+ /** Accumulate usage from all session entries (mirrors pi's built-in footer). */
55
+ export declare function collectUsage(sessionManager: ExtensionContext["sessionManager"]): UsageTotals;
56
+ export interface GitInfo {
57
+ /** Repo-root folder basename (the MAIN repo, stable across worktrees), or ~-path off-repo. */
58
+ folder: string;
59
+ inRepo: boolean;
60
+ /** Current branch, null off-repo. "detached" on detached HEAD. */
61
+ branch: string | null;
62
+ /** Linked-worktree label for [brackets], or null to hide. */
63
+ worktree: string | null;
64
+ }
65
+ /** Git probes `resolveWorktreeLabel` needs, injectable so the rules can be unit-tested. */
66
+ export interface WorktreeProbes {
67
+ isLinkedWorktree(repoRoot: string): boolean;
68
+ worktrees(repoRoot: string): string[];
69
+ }
70
+ /**
71
+ * Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
72
+ * >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
73
+ * context and earns no label; a [bracket] only disambiguates a secondary working tree.
74
+ */
75
+ export declare function resolveWorktreeLabel(repoRoot: string, branch: string | null, probes?: WorktreeProbes): string | null;
76
+ /**
77
+ * Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
78
+ * binary, and corrupt repos all degrade to a safe partial/empty result.
79
+ */
80
+ export declare function detectGitInfo(cwd: string, home: string | undefined): GitInfo;
81
+ /** Pure line-builder, exported for tests. All data injected; colors via theme. */
82
+ export declare function renderFooterLines(input: {
83
+ git: GitInfo;
84
+ model: string;
85
+ usage: UsageTotals;
86
+ contextPercent: number | null;
87
+ statuses: string[];
88
+ }, theme: Pick<Theme, "fg">, width: number): string[];
89
+ /**
90
+ * Create a footer factory that captures the session `ctx` (for session data)
91
+ * and returns the component `setFooter` expects. Called from the
92
+ * `session_start` handler in index.ts.
93
+ */
94
+ export declare function createYagniFooterFactory(ctx: ExtensionContext): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
95
+ render(width: number): string[];
96
+ invalidate(): void;
97
+ dispose(): void;
98
+ };
99
+ export {};
100
+ //# sourceMappingURL=footer.d.ts.map
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Custom footer for the YAGNI CLI — replaces pi's built-in footer.
3
+ *
4
+ * Renders three lines:
5
+ * 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
6
+ * 2. model · ↑in ↓out $cost · ctx% (session stats; context % is an integer)
7
+ * 3. extension statuses (branding, todo counter, mode) joined by " · "
8
+ *
9
+ * --- How to customize the status bar (for future tickets) ---
10
+ *
11
+ * LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
12
+ * The `pi` (ExtensionAPI) object passed to the factory has no UI methods —
13
+ * they live on `ctx.ui` (ExtensionUIContext), which is only bound after
14
+ * `_applyExtensionBindings` runs, immediately before the `session_start`
15
+ * event. So the footer must be set inside a `pi.on("session_start", ...)`
16
+ * handler, guarded by `ctx.mode === "tui"` (no footer in RPC/print mode).
17
+ * See index.ts's session_start handler for the wiring, and pi's
18
+ * `docs/extensions.md` § "Widgets, Status, and Footer" + the `custom-header.ts`
19
+ * example for the canonical pattern.
20
+ *
21
+ * FACTORY SIGNATURE: `setFooter((tui, theme, footerData) => Component)` where:
22
+ * - `tui` — the TUI instance (screen dimensions, focus)
23
+ * - `theme` — the current Theme (use `theme.fg("dim", text)` etc.)
24
+ * - `footerData` — ReadonlyFooterDataProvider: `getGitBranch()`,
25
+ * `getExtensionStatuses()` (statuses set via `ctx.ui.setStatus(key, text)`),
26
+ * `getAvailableProviderCount()`
27
+ * Model info, token stats, and context usage are NOT on `footerData` — they're
28
+ * on `ctx` (the ExtensionContext passed to the session_start handler):
29
+ * `ctx.model`, `ctx.sessionManager`, `ctx.getContextUsage()`. Thread them
30
+ * through a closure if the footer needs them (this module's
31
+ * `createYagniFooterFactory` does exactly that).
32
+ *
33
+ * COMPONENT CONTRACT: the returned object needs `render(width: number): string[]`
34
+ * (returns the lines to display, one string per row) and `invalidate()` (called
35
+ * when the component should re-render). Optionally `dispose()` for cleanup.
36
+ *
37
+ * REFERENCE: pi's built-in footer lives at
38
+ * `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js`
39
+ * — useful reference for what data to replicate and how to format it.
40
+ */
41
+ import { spawnSync } from "node:child_process";
42
+ import { statSync } from "node:fs";
43
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
+ export const BRANCH_MAX_WIDTH = 60;
46
+ const WORKTREE_MAX_WIDTH = 30;
47
+ /** Section separator: single space + middle dot + single space. */
48
+ const SEP = " · ";
49
+ /** Format token counts for compact footer display (mirrors pi's formatTokens). */
50
+ export function formatTokens(count) {
51
+ if (count < 1000)
52
+ return count.toString();
53
+ if (count < 10000)
54
+ return `${(count / 1000).toFixed(1)}k`;
55
+ if (count < 1000000)
56
+ return `${Math.round(count / 1000)}k`;
57
+ if (count < 10000000)
58
+ return `${(count / 1000000).toFixed(1)}M`;
59
+ return `${Math.round(count / 1000000)}M`;
60
+ }
61
+ /** Shorten cwd relative to home, like pi's built-in footer. */
62
+ export function formatCwd(cwd, home) {
63
+ if (!home)
64
+ return cwd;
65
+ const resolvedCwd = resolve(cwd);
66
+ const resolvedHome = resolve(home);
67
+ const rel = relative(resolvedHome, resolvedCwd);
68
+ const inside = rel === "" ||
69
+ (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
70
+ return inside ? (rel === "" ? "~" : `~/${rel}`) : cwd;
71
+ }
72
+ /** Accumulate usage from all session entries (mirrors pi's built-in footer). */
73
+ export function collectUsage(sessionManager) {
74
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
75
+ for (const entry of sessionManager.getEntries()) {
76
+ let u;
77
+ if (entry.type === "message" && entry.message.role === "assistant") {
78
+ u = entry.message.usage;
79
+ }
80
+ else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
81
+ u = entry.message.usage;
82
+ }
83
+ else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
84
+ u = entry.usage;
85
+ }
86
+ if (u) {
87
+ totals.input += u.input;
88
+ totals.output += u.output;
89
+ totals.cacheRead += u.cacheRead;
90
+ totals.cacheWrite += u.cacheWrite;
91
+ totals.cost += u.cost.total;
92
+ }
93
+ }
94
+ return totals;
95
+ }
96
+ /** End-cut ellipsis truncation (ANSI-aware) so the branch prefix stays readable. */
97
+ function truncateEnd(text, maxWidth) {
98
+ if (visibleWidth(text) <= maxWidth)
99
+ return text;
100
+ return truncateToWidth(text, maxWidth, "…");
101
+ }
102
+ function runGit(args, cwd) {
103
+ try {
104
+ const r = spawnSync("git", ["--no-optional-locks", ...args], {
105
+ cwd,
106
+ encoding: "utf8",
107
+ stdio: ["ignore", "pipe", "ignore"],
108
+ });
109
+ if (r.error || r.status !== 0)
110
+ return null;
111
+ return r.stdout.trim();
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ function gitRepoRoot(cwd) {
118
+ return runGit(["rev-parse", "--show-toplevel"], cwd) || null;
119
+ }
120
+ function gitBranch(repoRoot) {
121
+ const out = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot);
122
+ if (out)
123
+ return out;
124
+ // Detached HEAD, or repo with no commits yet.
125
+ return runGit(["rev-parse", "--verify", "HEAD"], repoRoot) ? "detached" : null;
126
+ }
127
+ function gitWorktrees(repoRoot) {
128
+ const out = runGit(["worktree", "list", "--porcelain"], repoRoot);
129
+ if (!out)
130
+ return [];
131
+ return out
132
+ .split("\n")
133
+ .filter((l) => l.startsWith("worktree "))
134
+ .map((l) => l.slice("worktree ".length).trim())
135
+ .filter(Boolean);
136
+ }
137
+ /** The MAIN repository root (stable across worktrees) — used for the folder name. */
138
+ function gitMainRepoRoot(repoRoot) {
139
+ const common = runGit(["rev-parse", "--git-common-dir"], repoRoot);
140
+ if (!common)
141
+ return repoRoot;
142
+ const abs = isAbsolute(common) ? common : join(repoRoot, common);
143
+ return basename(abs) === ".git" ? dirname(abs) : repoRoot;
144
+ }
145
+ /** Main checkout: .git is a directory. Linked worktree: .git is a "gitdir:" file. */
146
+ function isLinkedWorktree(repoRoot) {
147
+ try {
148
+ return statSync(join(repoRoot, ".git")).isFile();
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
154
+ const liveWorktreeProbes = { isLinkedWorktree, worktrees: gitWorktrees };
155
+ /**
156
+ * Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
157
+ * >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
158
+ * context and earns no label; a [bracket] only disambiguates a secondary working tree.
159
+ */
160
+ export function resolveWorktreeLabel(repoRoot, branch, probes = liveWorktreeProbes) {
161
+ if (!probes.isLinkedWorktree(repoRoot))
162
+ return null;
163
+ if (probes.worktrees(repoRoot).length < 2)
164
+ return null;
165
+ const currentDir = basename(repoRoot);
166
+ if (branch && currentDir === branch)
167
+ return null;
168
+ return truncateEnd(currentDir, WORKTREE_MAX_WIDTH);
169
+ }
170
+ /**
171
+ * Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
172
+ * binary, and corrupt repos all degrade to a safe partial/empty result.
173
+ */
174
+ export function detectGitInfo(cwd, home) {
175
+ const root = gitRepoRoot(cwd);
176
+ if (!root) {
177
+ return { folder: formatCwd(cwd, home), inRepo: false, branch: null, worktree: null };
178
+ }
179
+ const branch = gitBranch(root);
180
+ const worktree = resolveWorktreeLabel(root, branch);
181
+ return { folder: basename(gitMainRepoRoot(root)), inRepo: true, branch, worktree };
182
+ }
183
+ /** Context color: dim below 70, warning 70-90, error above 90. */
184
+ function contextColor(percent) {
185
+ if (percent === null)
186
+ return "dim";
187
+ if (percent > 90)
188
+ return "error";
189
+ if (percent > 70)
190
+ return "warning";
191
+ return "dim";
192
+ }
193
+ /** Pure line-builder, exported for tests. All data injected; colors via theme. */
194
+ export function renderFooterLines(input, theme, width) {
195
+ const dim = (s) => theme.fg("dim", s);
196
+ const sep = dim(SEP);
197
+ // Line 1: folder · [worktree] · branch
198
+ const line1Parts = [theme.fg("accent", input.git.folder)];
199
+ if (input.git.inRepo) {
200
+ if (input.git.worktree)
201
+ line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
202
+ if (input.git.branch)
203
+ line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
204
+ }
205
+ const line1 = truncateToWidth(line1Parts.join(sep), width, dim("…"));
206
+ // Line 2: model · ↑in ↓out $cost · ctx%
207
+ const statParts = [];
208
+ if (input.usage.input)
209
+ statParts.push(`↑${formatTokens(input.usage.input)}`);
210
+ if (input.usage.output)
211
+ statParts.push(`↓${formatTokens(input.usage.output)}`);
212
+ if (input.usage.cost)
213
+ statParts.push(`$${input.usage.cost.toFixed(3)}`);
214
+ const stats = statParts.join(" ");
215
+ const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
216
+ const line2Parts = [dim(input.model)];
217
+ if (stats)
218
+ line2Parts.push(dim(stats));
219
+ line2Parts.push(theme.fg(contextColor(input.contextPercent), percentText));
220
+ const line2 = truncateToWidth(line2Parts.join(sep), width, dim("…"));
221
+ // Line 3: extension statuses (branding, todo counter, mode), joined by " · ".
222
+ const statuses = input.statuses.map((s) => s.replace(/[\r\n\t]/g, " ").trim()).filter(Boolean);
223
+ const lines = [line1, line2];
224
+ if (statuses.length > 0) {
225
+ lines.push(truncateToWidth(dim(statuses.join(SEP)), width, dim("…")));
226
+ }
227
+ return lines;
228
+ }
229
+ /**
230
+ * Create a footer factory that captures the session `ctx` (for session data)
231
+ * and returns the component `setFooter` expects. Called from the
232
+ * `session_start` handler in index.ts.
233
+ */
234
+ export function createYagniFooterFactory(ctx) {
235
+ return (_tui, theme, footerData) => {
236
+ // Recompute git/worktree info only when the branch actually changes. Optional-
237
+ // chained: onBranchChange is typed on ReadonlyFooterDataProvider, but the runtime
238
+ // provider is whatever pi version is installed — guard so a mismatch can't break
239
+ // footer construction (worst case, git info just doesn't auto-invalidate).
240
+ let gitCache;
241
+ const unsubscribeBranch = footerData.onBranchChange?.(() => {
242
+ gitCache = undefined;
243
+ });
244
+ const gitInfo = () => {
245
+ if (!gitCache) {
246
+ gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
247
+ }
248
+ return gitCache;
249
+ };
250
+ return {
251
+ render(width) {
252
+ const statuses = [...footerData.getExtensionStatuses().entries()]
253
+ .sort(([a], [b]) => a.localeCompare(b))
254
+ .map(([, text]) => text);
255
+ return renderFooterLines({
256
+ git: gitInfo(),
257
+ model: ctx.model?.id ?? "no-model",
258
+ usage: collectUsage(ctx.sessionManager),
259
+ contextPercent: ctx.getContextUsage()?.percent ?? null,
260
+ statuses,
261
+ }, theme, width);
262
+ },
263
+ invalidate() {
264
+ gitCache = undefined;
265
+ },
266
+ dispose() {
267
+ unsubscribeBranch?.();
268
+ },
269
+ };
270
+ };
271
+ }
272
+ //# sourceMappingURL=footer.js.map
@@ -3,7 +3,6 @@ import { dirname, join } from "node:path";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
5
5
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
6
- import { registerBoostCommand } from "./boostCommand.js";
7
6
  import { makeAskYagniTool } from "./askYagniTool.js";
8
7
  import { makeReviewBusinessMatchTool } from "./reviewTool.js";
9
8
  import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
@@ -15,6 +14,7 @@ import { registerCostCommand } from "./costHud.js";
15
14
  import { isDebug } from "./diagnostics.js";
16
15
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
17
16
  import { codeStateHome } from "./stateHome.js";
17
+ import { createYagniFooterFactory } from "./footer.js";
18
18
  import { RerouteNotifier } from "./rerouteNotice.js";
19
19
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
20
20
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
@@ -111,7 +111,13 @@ export async function registerYagni(pi, deps = {}) {
111
111
  if (!evalMode) {
112
112
  installUncaughtExceptionMonitor({ baseUrl, getToken: getTokenFn, env: deps.env });
113
113
  }
114
- const catalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
114
+ const fullCatalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
115
+ // Lock the interactive session to the `advanced` tier only. The backend
116
+ // catalog returns all tiers, but only `advanced` is registered with the
117
+ // `yagni` provider, so /model and Ctrl+P show a single entry. Child
118
+ // processes (/go, subagents, advisor) fetch their own catalog and register
119
+ // their own provider, so they are unaffected by this filter.
120
+ const catalog = fullCatalog.filter((m) => m.id === "advanced");
115
121
  // YAG-471: the driver's own completions carry attribution headers read from
116
122
  // this process's env (YAGNI_SESSION_ID minted by the launcher; YAGNI_CALLER
117
123
  // defaults to "driver" when unset, i.e. every session that is not a /go
@@ -119,11 +125,11 @@ export async function registerYagni(pi, deps = {}) {
119
125
  pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
120
126
  const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
121
127
  pi.registerTool(makeAskYagniTool(toolOpts));
122
- // The peak-tier escalation for Balanced sessions (YAG-380). Registered
128
+ // The peak-tier escalation for Advanced sessions (YAG-380). Registered
123
129
  // UNCONDITIONALLY and gated at execute time on the live session model: pi's
124
130
  // picker can switch the model after activation, so a registration-time tier
125
- // check would both leave the tool live after switching away from Balanced and
126
- // hide it forever from a session that switched TO Balanced. One state handle
131
+ // check would both leave the tool live after switching away from Advanced and
132
+ // hide it forever from a session that switched TO Advanced. One state handle
127
133
  // per session, shared with /advise so they draw on the same cap.
128
134
  const advisorState = makeAdvisorState();
129
135
  const askAdvisorTool = makeAskAdvisorTool({ state: advisorState });
@@ -131,12 +137,6 @@ export async function registerYagni(pi, deps = {}) {
131
137
  // /advise runs the SAME tool, sharing the state handle, so a manual consult
132
138
  // draws on the same cap rather than opening a side channel around it.
133
139
  registerAdviseCommand(pi, askAdvisorTool);
134
- // /boost — the sanctioned session-scoped escalation to Peak (spec §7,
135
- // YAG-380 follow-on). Unlike /advise's per-call consult, this flips the
136
- // DRIVER's own live model to peak until /boost off or the session ends.
137
- // The returned handle threads isBoosted() into /cost below, the same way
138
- // advisorState threads into advisorSubtotal.
139
- const boostCommand = registerBoostCommand(pi, { env });
140
140
  // The differentiated business-grounded tools (loop bricks): review a change
141
141
  // for business fit, rank the next work by business priority, and record the
142
142
  // engineering rationale back onto the work-item.
@@ -248,11 +248,6 @@ export async function registerYagni(pi, deps = {}) {
248
248
  // fallback line; the server-authoritative line already counts advisor
249
249
  // spend as an ordinary caller row.
250
250
  advisorSubtotal: () => formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS),
251
- // YAG-380 follow-on: a boosted session that only chats never produces a
252
- // server-side boosted row (see boostCommand.ts's KNOWN-asymmetry
253
- // docblock), so /cost needs the live client-side toggle on top of
254
- // whatever the server rows show.
255
- isBoosted: () => boostCommand.isBoosted(),
256
251
  fetchHeadroom: async (signal) => {
257
252
  try {
258
253
  const res = await resilientFetch(`${baseUrl}/api/yagni-code/credits`, { method: "GET", headers: { authorization: `Bearer ${getTokenFn() ?? ""}` } }, { fetchImpl: authedFetch, signal, policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: COST_FETCH_TIMEOUT_MS, jitterRatio: 0 } });
@@ -487,6 +482,12 @@ export async function registerYagni(pi, deps = {}) {
487
482
  // setHeader replaces the built-in header in place (verified against pi
488
483
  // 0.80.2 setExtensionHeader); the factory returns a simple Text component.
489
484
  ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme)));
485
+ // Replace the built-in footer with the YAGNI status bar: folder +
486
+ // [worktree] + branch on line 1, model + token/cost stats + integer
487
+ // context % on line 2, and extension statuses (brand, todos, mode) on
488
+ // line 3. The factory captures ctx so the footer can read session data
489
+ // (token stats, context usage) that isn't on the footerData provider.
490
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx)(tui, theme, footerData));
490
491
  }
491
492
  // Best-effort, once at session start: if the token is at or near expiry, say
492
493
  // so via a single notice so a long session does not silently start 401-ing
package/dist/launch.js CHANGED
@@ -99,23 +99,16 @@ export function buildLaunch(creds, passthroughArgs, opts) {
99
99
  // Always load our extension. Default the provider to `yagni` unless the user
100
100
  // explicitly chose one (so power users can still point pi elsewhere).
101
101
  const userChoseProvider = passthroughArgs.some(arg => arg === "--provider" || arg.startsWith("--provider="));
102
- // Default the model to the `balanced` tier (YAG-380). Balanced drives on the
103
- // judgment-grade `advanced` model and additionally lets the session escalate
104
- // hard calls to `peak` through the capped ask_advisor tool. So it is strictly
105
- // additive: no session reasons worse than plain advanced, and the hard ones
106
- // can reason better. A user `--model` (e.g. `standard` or `efficient`) still
107
- // wins. Without this, pi's default-model heuristic could land an interactive
108
- // session on a weaker tier.
109
- // Detect if the user explicitly set a model, whether via "--model" as a separate
110
- // argument or using the equals form "--model=efficient". The previous check only
111
- // caught the separate form, causing a duplicate "--model balanced" to be added
112
- // when the equals form was used.
102
+ // Default the model to the `advanced` tier. The model is locked: the
103
+ // catalog is filtered to only `advanced` (see index.ts), so the user
104
+ // cannot switch to a different tier via /model or Ctrl+P. The proxy still
105
+ // resolves the tier to the concrete backing model.
113
106
  const userChoseModel = passthroughArgs.some(arg => arg === "--model" || arg.startsWith("--model="));
114
107
  const argv = [
115
108
  "-e",
116
109
  opts.extensionPath,
117
110
  ...(userChoseProvider ? [] : ["--provider", "yagni"]),
118
- ...(userChoseModel ? [] : ["--model", "balanced"]),
111
+ ...(userChoseModel ? [] : ["--model", "advanced"]),
119
112
  ...(opts.extraAgentArgs ?? []),
120
113
  ...passthroughArgs,
121
114
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.2.1-staging.1027.1",
3
+ "version": "0.2.1-staging.1032.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)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.83.0",
39
39
  "typebox": "^1.1.38"
40
40
  },
41
- "yagniSourceSha": "33b62c8a01064f7681085e441aa00b02693b13c7"
41
+ "yagniSourceSha": "7629e1f12b3d8e06e68101daadd2d8258dd271e9"
42
42
  }
@@ -1,144 +0,0 @@
1
- /**
2
- * `/boost` — the sanctioned session-scoped escalation to Peak (spec §7).
3
- *
4
- * The session default is the `balanced` tier. `/boost` flips the
5
- * DRIVER session's live model to `peak` until `/boost off` or the process
6
- * exits; `peak` stays directly pickable through pi's own model picker too,
7
- * this just adds a one-word lever plus an attribution flag.
8
- *
9
- * Two halves, same split as `advisor.ts` / `permission.ts`:
10
- *
11
- * - `boostOn` / `boostOff` are PURE. They own every transition and the exact
12
- * notice copy; no pi, no I/O, no env. That is what needs exhaustive tests.
13
- * - `registerBoostCommand` is the wiring: it applies the pure result's
14
- * `targetModelId` through pi's real model-switch API, toggles
15
- * `process.env.YAGNI_BOOST`, and paints the notice + a status-bar chip.
16
- *
17
- * The model-switch API (verified against pi 0.83.0's typings, not guessed):
18
- * `ctx.modelRegistry.find(provider, modelId)` resolves a tier id to a
19
- * `Model`, and `pi.setModel(model)` (the top-level `ExtensionAPI` method, NOT
20
- * `ctx.setModel` — `ExtensionCommandContext` does not expose a setter) applies
21
- * it to the live session, resolving `false` when no API key is configured.
22
- * The `yagni` provider's catalog entries key `id` on the tier id itself (see
23
- * `provider.ts`), so `peak` is the pi model id for the peak tier.
24
- *
25
- * State lives in the registration closure — module/session-scoped, mirroring
26
- * `costHud.ts`'s accumulator — because a session's process exit is the only
27
- * "end" there is; nothing needs to persist across it. `registerBoostCommand`
28
- * returns a small `{ isBoosted() }` handle onto that same closure so other
29
- * registrations (currently just `/cost`, see below) can read the live flag
30
- * without a second source of truth.
31
- *
32
- * Picker-drift guard (spec review item 3): pi's OWN model picker (Ctrl+P,
33
- * `/model`) can change the live model independently of `/boost` in either
34
- * direction, so `state.active` can go stale relative to `ctx.model.id`. Both
35
- * halves read the live model and reconcile rather than trusting `state.active`
36
- * blindly:
37
- *
38
- * - `boostOn` while already active but the live model has drifted OFF peak
39
- * (the user cycled models mid-boost without `/boost off`): re-applies
40
- * peak instead of a silent "Already boosted." that would leave the
41
- * driver quietly running a cheaper tier under an attribution header that
42
- * claims otherwise. The remembered `priorModelId` is left untouched — the
43
- * tier to restore is still whatever was active before the ORIGINAL boost,
44
- * not the tier the user happened to drift to.
45
- * - `boostOff` while active but the live model is no longer peak (same
46
- * drift, encountered from the other command): the user already left
47
- * boost manually, so forcing a switch back to the remembered prior tier
48
- * would clobber a choice they just made on purpose. Instead this clears
49
- * `/boost`'s own bookkeeping (state, env, chip) without touching the
50
- * model, and names where they actually landed.
51
- *
52
- * KNOWN asymmetry, documented rather than worked around: `YAGNI_BOOST=1`
53
- * while boosted makes every CHILD process spawned during the boost (a /go
54
- * run, a subagent, an advisor consult) inherit it, and those children's
55
- * completions carry `x-yagni-boost` because their provider is registered
56
- * fresh per child. The DRIVER's own completions do not gain that header
57
- * retroactively — `buildYagniProvider`'s headers are baked into the
58
- * `registerProvider` call once, at session start, from the env snapshot at
59
- * that moment (see `provider.ts` / `attributionHeaders`). Re-registering the
60
- * provider mid-session to pick up a new header is explicitly NOT the fix
61
- * here: it would race the in-flight request the switch itself triggers and
62
- * has no test coverage as a live-swap path.
63
- *
64
- * This is exactly why `/cost` cannot rely on server rows alone: the server's
65
- * per-tier "Boosted (tier) spend" subtotal (`formatServerCostLines`, Task 7)
66
- * only ever sees rows carrying `x-yagni-boost` — i.e. `/go` children, never
67
- * the driver's own turns. A session that only chats while boosted would
68
- * otherwise show no boost line at all. `registerCostCommand`'s `isBoosted`
69
- * dep (threaded from THIS module's return handle, the same way
70
- * `advisorSubtotal` is threaded from `advisor.ts`) closes that gap: `/cost`
71
- * appends a fixed "Boost is on. Driver turns bill at the peak tier." line
72
- * whenever the live toggle is on, on BOTH the server-authoritative and the
73
- * local-fallback branch, independent of what server rows happen to show.
74
- */
75
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
76
- /** Session-scoped boost state, held in the registration closure. */
77
- export interface BoostState {
78
- active: boolean;
79
- /** The tier to restore on `/boost off`. Set only while `active`. */
80
- priorModelId?: string;
81
- }
82
- /** The pure outcome of a `/boost` transition. */
83
- export interface BoostResult {
84
- state: BoostState;
85
- /** Exact notice text to surface to the user. */
86
- notice: string;
87
- /** The tier id to switch the session to, or undefined for a pure no-op. */
88
- targetModelId?: string;
89
- }
90
- /**
91
- * Turn boost on. PURE.
92
- *
93
- * - Already active AND the live model is still `peak`: genuine no-op,
94
- * "Already boosted." (no switch — nothing about the prior tier changes).
95
- * - Already active but the live model has drifted off `peak` (picker-drift
96
- * guard 3b, see module docblock): re-applies `peak`, keeping the ORIGINAL
97
- * `priorModelId` rather than adopting the drifted-to tier.
98
- * - Not active, current model IS already `peak`: activates anyway (so
99
- * attribution and `/boost off` behave correctly) with a distinct notice,
100
- * remembering `peak` itself as the tier to restore.
101
- * - Not active, otherwise: remembers the current tier, targets `peak`.
102
- */
103
- export declare function boostOn(state: BoostState, currentModelId: string | undefined): BoostResult;
104
- /**
105
- * Turn boost off. PURE.
106
- *
107
- * - Not active: no-op, "Boost is not on."
108
- * - Active but the live model is no longer `peak` (picker-drift guard 3a,
109
- * see module docblock): the user already left boost manually. Clears
110
- * `/boost`'s own bookkeeping WITHOUT a switch — forcing one back to the
111
- * remembered prior tier would clobber a choice just made on purpose — and
112
- * names the model they actually landed on.
113
- * - Active and still on `peak`: restores the remembered prior tier (falling
114
- * back to the session default when none was recorded, which should not
115
- * normally happen). When the prior tier is itself `peak` (the
116
- * already-on-Peak activation path), the resulting switch is a no-op in
117
- * effect — still applied, harmlessly.
118
- */
119
- export declare function boostOff(state: BoostState, currentModelId: string | undefined): BoostResult;
120
- /** Injectable seams for `registerBoostCommand`. */
121
- export interface RegisterBoostCommandDeps {
122
- /** Environment `YAGNI_BOOST` is toggled on. Defaults to `process.env`. */
123
- env?: NodeJS.ProcessEnv;
124
- }
125
- /** What `registerBoostCommand` hands back so other registrations (`/cost`) can read the live flag. */
126
- export interface BoostCommandHandle {
127
- /** Whether the session is currently boosted, read fresh off the same closure `/boost` mutates. */
128
- isBoosted(): boolean;
129
- }
130
- /**
131
- * Register the `/boost` command.
132
- *
133
- * `/boost` (no args) turns boost on; `/boost off` turns it off; any other
134
- * argument shows a usage notice and changes nothing. Every transition that
135
- * requires a model switch runs it through pi's real API and is guarded end to
136
- * end: state and `YAGNI_BOOST` are only ever committed together, AFTER the
137
- * switch succeeds (or is deliberately skipped — the picker-drift guards), so
138
- * a half-applied boost (env set but model unchanged, or vice versa) can never
139
- * happen. A failed ON reports "Boost failed."; a failed OFF reports a
140
- * DISTINCT message, because the failure mode is materially worse — the
141
- * driver is left still running (and billing) on Peak, not merely unchanged.
142
- */
143
- export declare function registerBoostCommand(pi: ExtensionAPI, deps?: RegisterBoostCommandDeps): BoostCommandHandle;
144
- //# sourceMappingURL=boostCommand.d.ts.map
@@ -1,263 +0,0 @@
1
- /**
2
- * `/boost` — the sanctioned session-scoped escalation to Peak (spec §7).
3
- *
4
- * The session default is the `balanced` tier. `/boost` flips the
5
- * DRIVER session's live model to `peak` until `/boost off` or the process
6
- * exits; `peak` stays directly pickable through pi's own model picker too,
7
- * this just adds a one-word lever plus an attribution flag.
8
- *
9
- * Two halves, same split as `advisor.ts` / `permission.ts`:
10
- *
11
- * - `boostOn` / `boostOff` are PURE. They own every transition and the exact
12
- * notice copy; no pi, no I/O, no env. That is what needs exhaustive tests.
13
- * - `registerBoostCommand` is the wiring: it applies the pure result's
14
- * `targetModelId` through pi's real model-switch API, toggles
15
- * `process.env.YAGNI_BOOST`, and paints the notice + a status-bar chip.
16
- *
17
- * The model-switch API (verified against pi 0.83.0's typings, not guessed):
18
- * `ctx.modelRegistry.find(provider, modelId)` resolves a tier id to a
19
- * `Model`, and `pi.setModel(model)` (the top-level `ExtensionAPI` method, NOT
20
- * `ctx.setModel` — `ExtensionCommandContext` does not expose a setter) applies
21
- * it to the live session, resolving `false` when no API key is configured.
22
- * The `yagni` provider's catalog entries key `id` on the tier id itself (see
23
- * `provider.ts`), so `peak` is the pi model id for the peak tier.
24
- *
25
- * State lives in the registration closure — module/session-scoped, mirroring
26
- * `costHud.ts`'s accumulator — because a session's process exit is the only
27
- * "end" there is; nothing needs to persist across it. `registerBoostCommand`
28
- * returns a small `{ isBoosted() }` handle onto that same closure so other
29
- * registrations (currently just `/cost`, see below) can read the live flag
30
- * without a second source of truth.
31
- *
32
- * Picker-drift guard (spec review item 3): pi's OWN model picker (Ctrl+P,
33
- * `/model`) can change the live model independently of `/boost` in either
34
- * direction, so `state.active` can go stale relative to `ctx.model.id`. Both
35
- * halves read the live model and reconcile rather than trusting `state.active`
36
- * blindly:
37
- *
38
- * - `boostOn` while already active but the live model has drifted OFF peak
39
- * (the user cycled models mid-boost without `/boost off`): re-applies
40
- * peak instead of a silent "Already boosted." that would leave the
41
- * driver quietly running a cheaper tier under an attribution header that
42
- * claims otherwise. The remembered `priorModelId` is left untouched — the
43
- * tier to restore is still whatever was active before the ORIGINAL boost,
44
- * not the tier the user happened to drift to.
45
- * - `boostOff` while active but the live model is no longer peak (same
46
- * drift, encountered from the other command): the user already left
47
- * boost manually, so forcing a switch back to the remembered prior tier
48
- * would clobber a choice they just made on purpose. Instead this clears
49
- * `/boost`'s own bookkeeping (state, env, chip) without touching the
50
- * model, and names where they actually landed.
51
- *
52
- * KNOWN asymmetry, documented rather than worked around: `YAGNI_BOOST=1`
53
- * while boosted makes every CHILD process spawned during the boost (a /go
54
- * run, a subagent, an advisor consult) inherit it, and those children's
55
- * completions carry `x-yagni-boost` because their provider is registered
56
- * fresh per child. The DRIVER's own completions do not gain that header
57
- * retroactively — `buildYagniProvider`'s headers are baked into the
58
- * `registerProvider` call once, at session start, from the env snapshot at
59
- * that moment (see `provider.ts` / `attributionHeaders`). Re-registering the
60
- * provider mid-session to pick up a new header is explicitly NOT the fix
61
- * here: it would race the in-flight request the switch itself triggers and
62
- * has no test coverage as a live-swap path.
63
- *
64
- * This is exactly why `/cost` cannot rely on server rows alone: the server's
65
- * per-tier "Boosted (tier) spend" subtotal (`formatServerCostLines`, Task 7)
66
- * only ever sees rows carrying `x-yagni-boost` — i.e. `/go` children, never
67
- * the driver's own turns. A session that only chats while boosted would
68
- * otherwise show no boost line at all. `registerCostCommand`'s `isBoosted`
69
- * dep (threaded from THIS module's return handle, the same way
70
- * `advisorSubtotal` is threaded from `advisor.ts`) closes that gap: `/cost`
71
- * appends a fixed "Boost is on. Driver turns bill at the peak tier." line
72
- * whenever the live toggle is on, on BOTH the server-authoritative and the
73
- * local-fallback branch, independent of what server rows happen to show.
74
- */
75
- /** The pi provider name the YAGNI catalog registers under (see `provider.ts`). */
76
- const YAGNI_PROVIDER = "yagni";
77
- /** The tier `/boost` escalates to. */
78
- const BOOST_TIER = "peak";
79
- /** The session default tier, restored when no prior tier is known. */
80
- const DEFAULT_TIER = "balanced";
81
- /** Tier ids are lowercase; every known tier's display name is just Title Case of the id. */
82
- function displayTierName(id) {
83
- return id.length === 0 ? id : id.charAt(0).toUpperCase() + id.slice(1);
84
- }
85
- /**
86
- * Turn boost on. PURE.
87
- *
88
- * - Already active AND the live model is still `peak`: genuine no-op,
89
- * "Already boosted." (no switch — nothing about the prior tier changes).
90
- * - Already active but the live model has drifted off `peak` (picker-drift
91
- * guard 3b, see module docblock): re-applies `peak`, keeping the ORIGINAL
92
- * `priorModelId` rather than adopting the drifted-to tier.
93
- * - Not active, current model IS already `peak`: activates anyway (so
94
- * attribution and `/boost off` behave correctly) with a distinct notice,
95
- * remembering `peak` itself as the tier to restore.
96
- * - Not active, otherwise: remembers the current tier, targets `peak`.
97
- */
98
- export function boostOn(state, currentModelId) {
99
- if (state.active) {
100
- if (currentModelId === BOOST_TIER) {
101
- return { state, notice: "Already boosted." };
102
- }
103
- return {
104
- state,
105
- notice: `Re-boosted to Peak. /boost off to return to ${displayTierName(state.priorModelId ?? DEFAULT_TIER)}.`,
106
- targetModelId: BOOST_TIER,
107
- };
108
- }
109
- if (currentModelId === BOOST_TIER) {
110
- return {
111
- state: { active: true, priorModelId: BOOST_TIER },
112
- notice: "Already on Peak. /boost off returns to Peak.",
113
- targetModelId: BOOST_TIER,
114
- };
115
- }
116
- return {
117
- state: { active: true, priorModelId: currentModelId },
118
- notice: `Boosted to Peak. /boost off to return to ${displayTierName(currentModelId ?? DEFAULT_TIER)}.`,
119
- targetModelId: BOOST_TIER,
120
- };
121
- }
122
- /**
123
- * Turn boost off. PURE.
124
- *
125
- * - Not active: no-op, "Boost is not on."
126
- * - Active but the live model is no longer `peak` (picker-drift guard 3a,
127
- * see module docblock): the user already left boost manually. Clears
128
- * `/boost`'s own bookkeeping WITHOUT a switch — forcing one back to the
129
- * remembered prior tier would clobber a choice just made on purpose — and
130
- * names the model they actually landed on.
131
- * - Active and still on `peak`: restores the remembered prior tier (falling
132
- * back to the session default when none was recorded, which should not
133
- * normally happen). When the prior tier is itself `peak` (the
134
- * already-on-Peak activation path), the resulting switch is a no-op in
135
- * effect — still applied, harmlessly.
136
- */
137
- export function boostOff(state, currentModelId) {
138
- if (!state.active) {
139
- return { state, notice: "Boost is not on." };
140
- }
141
- if (currentModelId !== BOOST_TIER) {
142
- return {
143
- state: { active: false, priorModelId: undefined },
144
- notice: `Boost off. Leaving the model on ${displayTierName(currentModelId ?? DEFAULT_TIER)}.`,
145
- };
146
- }
147
- const priorId = state.priorModelId ?? DEFAULT_TIER;
148
- return {
149
- state: { active: false, priorModelId: undefined },
150
- notice: `Back to ${displayTierName(priorId)}.`,
151
- targetModelId: priorId,
152
- };
153
- }
154
- /**
155
- * Resolve `tierId` to a live `Model` and apply it via `pi.setModel`.
156
- *
157
- * On a registry lookup miss, `allowFallback` (true only on the OFF path —
158
- * MINOR 5) retries once against the session default tier rather than
159
- * stranding the user on Peak over one missing catalog entry. Returns the
160
- * tier id that was ACTUALLY applied, so the caller can tell whether the
161
- * fallback fired and adjust the notice to name it honestly.
162
- *
163
- * @throws when neither the requested tier nor (if allowed) the fallback
164
- * resolves, or when `pi.setModel` itself throws or resolves `false`.
165
- */
166
- async function applyTier(pi, ctx, tierId, allowFallback) {
167
- let model = ctx.modelRegistry?.find(YAGNI_PROVIDER, tierId);
168
- let applied = tierId;
169
- if (!model && allowFallback && tierId !== DEFAULT_TIER) {
170
- model = ctx.modelRegistry?.find(YAGNI_PROVIDER, DEFAULT_TIER);
171
- applied = DEFAULT_TIER;
172
- }
173
- if (!model) {
174
- throw new Error(`Unknown YAGNI tier "${tierId}".`);
175
- }
176
- const ok = await pi.setModel(model);
177
- if (!ok) {
178
- throw new Error("setModel reported no API key configured.");
179
- }
180
- return applied;
181
- }
182
- /**
183
- * Register the `/boost` command.
184
- *
185
- * `/boost` (no args) turns boost on; `/boost off` turns it off; any other
186
- * argument shows a usage notice and changes nothing. Every transition that
187
- * requires a model switch runs it through pi's real API and is guarded end to
188
- * end: state and `YAGNI_BOOST` are only ever committed together, AFTER the
189
- * switch succeeds (or is deliberately skipped — the picker-drift guards), so
190
- * a half-applied boost (env set but model unchanged, or vice versa) can never
191
- * happen. A failed ON reports "Boost failed."; a failed OFF reports a
192
- * DISTINCT message, because the failure mode is materially worse — the
193
- * driver is left still running (and billing) on Peak, not merely unchanged.
194
- */
195
- export function registerBoostCommand(pi, deps = {}) {
196
- const env = deps.env ?? process.env;
197
- let state = { active: false };
198
- const paintStatus = (ctx, active) => {
199
- try {
200
- if (ctx.hasUI)
201
- ctx.ui.setStatus?.("yagni-boost", active ? "⚡ Peak" : undefined);
202
- }
203
- catch {
204
- // The chip is chrome; never let it break /boost.
205
- }
206
- };
207
- pi.registerCommand("boost", {
208
- description: "Escalate this session's model to Peak until /boost off or the session ends. /boost off returns to the prior tier.",
209
- handler: async (args, ctx) => {
210
- const notify = (message, type) => {
211
- if (ctx.hasUI)
212
- ctx.ui.notify(message, type);
213
- };
214
- // MINOR 4: case-insensitive "off" match, mirroring /mode's arg handling.
215
- const arg = args.trim().toLowerCase();
216
- if (arg !== "" && arg !== "off") {
217
- notify("Usage: /boost (turn on) or /boost off.", "warning");
218
- return;
219
- }
220
- const isOff = arg === "off";
221
- const wasActive = state.active;
222
- const result = isOff ? boostOff(state, ctx.model?.id) : boostOn(state, ctx.model?.id);
223
- try {
224
- let notice = result.notice;
225
- if (result.targetModelId) {
226
- const applied = await applyTier(pi, ctx, result.targetModelId, isOff);
227
- if (applied !== result.targetModelId) {
228
- // MINOR 5: the remembered/target tier no longer resolves in the
229
- // catalog; we landed on the session default instead of
230
- // stranding the user on Peak. Name the fallback so the notice
231
- // stays honest about what actually happened.
232
- notice = `Back to ${displayTierName(applied)} (could not restore ${displayTierName(result.targetModelId)}).`;
233
- }
234
- }
235
- state = result.state;
236
- // MINOR 6: only touch the env on a real active/inactive flip. Both
237
- // pure no-op paths ("Already boosted.", "Boost is not on.") return
238
- // `state` unchanged, so this never fires for them — an inherited
239
- // YAGNI_BOOST from outside this session is left exactly as found.
240
- if (state.active !== wasActive) {
241
- if (state.active) {
242
- env.YAGNI_BOOST = "1";
243
- }
244
- else {
245
- delete env.YAGNI_BOOST;
246
- }
247
- }
248
- notify(notice, "info");
249
- paintStatus(ctx, state.active);
250
- }
251
- catch {
252
- // IMPORTANT 1: a failed OFF is materially worse than a failed ON —
253
- // the driver is STILL on Peak, still billing at Peak rates, which a
254
- // generic "Boost failed." does not convey. Neither `state` nor
255
- // `YAGNI_BOOST` was touched above (the throw lands before both), so
256
- // the chip is also left exactly as it was: still lit.
257
- notify(isOff ? "Could not leave boost. Still on Peak; try /boost off again." : "Boost failed.", "error");
258
- }
259
- },
260
- });
261
- return { isBoosted: () => state.active };
262
- }
263
- //# sourceMappingURL=boostCommand.js.map