@yagni-app/code-staging 1.1.4-staging.1415.1 → 1.1.4-staging.1419.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 +1 -1
- package/dist/extension/askAdvisorTool.js +3 -1
- package/dist/extension/costHud.d.ts +51 -1
- package/dist/extension/costHud.js +72 -2
- package/dist/extension/errorSink.d.ts +1 -1
- package/dist/extension/index.d.ts +3 -1
- package/dist/extension/index.js +81 -17
- package/dist/extension/sandbox/session.js +4 -2
- package/dist/extension/scratchpad.d.ts +15 -1
- package/dist/extension/scratchpad.js +27 -4
- package/dist/extension/subagentRender.d.ts +18 -3
- package/dist/extension/subagentRender.js +14 -2
- package/dist/extension/subagents.js +4 -1
- package/dist/extension/tierCommand.d.ts +183 -0
- package/dist/extension/tierCommand.js +381 -0
- package/dist/launch.js +7 -4
- package/package.json +2 -2
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>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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";
|
package/dist/extension/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { makeRecordDecisionTool } from "./recordDecisionTool.js";
|
|
|
17
17
|
import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
|
|
18
18
|
import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_DRIVER_GROUNDING_FREE, YAGNI_IDENTITY_GROUNDING_FREE, YAGNI_IDENTITY_ULTRA, YAGNI_IDENTITY_ULTRA_GROUNDING_FREE } from "./branding.js";
|
|
19
19
|
import { claudeRulesSection } from "./claudeRules.js";
|
|
20
|
-
import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadSection } from "./scratchpad.js";
|
|
20
|
+
import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadOwnerRootFor, scratchpadSection } from "./scratchpad.js";
|
|
21
21
|
import { registerCostCommand } from "./costHud.js";
|
|
22
22
|
import { isDebug } from "./diagnostics.js";
|
|
23
23
|
import { logEvent } from "./errorSink.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
|
-
//
|
|
294
|
-
//
|
|
295
|
-
// `
|
|
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
|
|
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
|
|
@@ -1321,24 +1339,69 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
1321
1339
|
// so the very first turn already carries the section. Fails closed: no
|
|
1322
1340
|
// sessionId (a bare pi run) or a failed mkdir means no section, and the mkdir
|
|
1323
1341
|
// failure is logged so a missing scratchpad is not silent.
|
|
1342
|
+
const scratchpadTmpRoot = env[SCRATCHPAD_TMPDIR_ENV] || undefined;
|
|
1324
1343
|
const scratchpadDirPath = scratchpadDirFor({
|
|
1325
1344
|
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
1326
1345
|
cwd: process.cwd(),
|
|
1327
|
-
tmp:
|
|
1346
|
+
tmp: scratchpadTmpRoot,
|
|
1328
1347
|
});
|
|
1329
1348
|
let scratchpadSectionText;
|
|
1330
1349
|
if (scratchpadDirPath) {
|
|
1331
|
-
const ensured = ensureScratchpadDir(scratchpadDirPath
|
|
1350
|
+
const ensured = ensureScratchpadDir(scratchpadDirPath, undefined, {
|
|
1351
|
+
onError: (errorCode) => {
|
|
1352
|
+
logEvent({
|
|
1353
|
+
source: "scratchpad",
|
|
1354
|
+
level: "error",
|
|
1355
|
+
event: "scratchpad_mkdir_failed",
|
|
1356
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
1357
|
+
fields: { path: scratchpadDirPath, errorCode },
|
|
1358
|
+
});
|
|
1359
|
+
},
|
|
1360
|
+
});
|
|
1332
1361
|
if (ensured) {
|
|
1333
1362
|
scratchpadSectionText = scratchpadSection(ensured);
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1363
|
+
// Claude Code parity: srt reads CLAUDE_CODE_TMPDIR from THIS process's
|
|
1364
|
+
// env when wrapping a sandboxed bash command and forces the child's
|
|
1365
|
+
// TMPDIR onto it. Point it at the per-user scratchpad OWNER ROOT (not
|
|
1366
|
+
// the scratchpad itself) so sandboxed-bash temp files land in the same
|
|
1367
|
+
// per-user tree the scratchpad lives in, under a path SHORT enough for
|
|
1368
|
+
// named unix-socket binds (the test-runner IPC pattern — a deep
|
|
1369
|
+
// scratchpad TMPDIR trips macOS's 104-char sun_path limit with EINVAL).
|
|
1370
|
+
// The scratchpad remains the sanctioned staging place by PRIORITY (the
|
|
1371
|
+
// prompt section outranks $TMPDIR), not by path convergence.
|
|
1372
|
+
//
|
|
1373
|
+
// Production note: children (subagents, /go stages) inherit this env
|
|
1374
|
+
// because `env` IS process.env there and the runner spawns with it. A
|
|
1375
|
+
// harness injecting deps.env sees the write only in that object —
|
|
1376
|
+
// spawned children of such a harness do not, by construction of the
|
|
1377
|
+
// runner's own env seam.
|
|
1378
|
+
//
|
|
1379
|
+
// Unset when there is no scratchpad (no session id, or the mkdir
|
|
1380
|
+
// failed) — srt keeps its /tmp/claude default and nothing changes for
|
|
1381
|
+
// those sessions.
|
|
1382
|
+
if (!env.CLAUDE_CODE_TMPDIR) {
|
|
1383
|
+
const ownerRoot = scratchpadOwnerRootFor({ tmp: scratchpadTmpRoot });
|
|
1384
|
+
env.CLAUDE_CODE_TMPDIR = ownerRoot;
|
|
1385
|
+
logEvent({
|
|
1386
|
+
source: "scratchpad",
|
|
1387
|
+
level: "info",
|
|
1388
|
+
event: "scratchpad_tmpdir_pinned",
|
|
1389
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
1390
|
+
fields: { path: ownerRoot },
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
// The trail must distinguish "pinned" from "respected an operator
|
|
1395
|
+
// override" — no path value here (the explicit value is the
|
|
1396
|
+
// operator's, not ours to echo).
|
|
1397
|
+
logEvent({
|
|
1398
|
+
source: "scratchpad",
|
|
1399
|
+
level: "info",
|
|
1400
|
+
event: "scratchpad_tmpdir_pin_skipped",
|
|
1401
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
1402
|
+
fields: {},
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1342
1405
|
}
|
|
1343
1406
|
}
|
|
1344
1407
|
// The condensed Claude Code-style transcript: the seven built-ins re-render
|
|
@@ -1898,7 +1961,8 @@ export { makeDecisionCapture, CAPTURE_DEBOUNCE_MS } from "./decisionCapture.js";
|
|
|
1898
1961
|
// M1: ambient judgment recall appended to `read` results (pure cores + fetch).
|
|
1899
1962
|
export { registerAmbientRecall, fetchRecall, formatRecallBlock, toRepoRelativePath, normalizeRecall, RECALL_TIMEOUT_MS, RECALL_MIN_DECISIONS, } from "./recall.js";
|
|
1900
1963
|
// P2: the session cost accumulator + /cost command.
|
|
1901
|
-
export { makeCostAccumulator, formatCostLine, registerCostCommand } from "./costHud.js";
|
|
1964
|
+
export { makeCostAccumulator, formatCostLine, registerCostCommand, cacheHitPercent, spendTokenTotals, } from "./costHud.js";
|
|
1965
|
+
export { SELECTABLE_TIERS, SESSION_START_TIER, formatStartCorrection, formatStartCorrectionFailed, formatTierStatus, formatTierSwitchFailed, formatTierSwitched, isSelectableTier, orderDriverCatalog, parseTierArg, registerStartTierGuard, registerTierCommand, startTierCorrection, } from "./tierCommand.js";
|
|
1902
1966
|
// W4 trust plumbing: the refreshing TokenProvider (kills the boot snapshot) and
|
|
1903
1967
|
// the ONE 401 refresh-then-retry-once fetch seam every tool shares.
|
|
1904
1968
|
export { makeTokenProvider, makeAuthedFetch, persistRotationToProfile, PROACTIVE_REFRESH_WINDOW_MS, REFRESH_RETRY_MS, } from "./tokenProvider.js";
|
|
@@ -118,8 +118,10 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
|
|
|
118
118
|
const heredocNote = "Caveat: heredocs (<<EOF) write temp files relative to the current directory on macOS bash 3.2 — from an unwritable cwd they fail; prefer printf or redirect from a file in the working directory.";
|
|
119
119
|
// $TMPDIR guidance (Claude Code parity, always-on): the sandbox sets
|
|
120
120
|
// TMPDIR to the per-user sandbox-writable temp dir; /tmp itself is NOT
|
|
121
|
-
// writable.
|
|
122
|
-
|
|
121
|
+
// writable. When the system prompt names a scratchpad directory, that
|
|
122
|
+
// directory takes priority for temporary files — stated here so the two
|
|
123
|
+
// guidance lines never read as mutually exclusive always-rules.
|
|
124
|
+
const tmpdirNote = "For temporary files, use the $TMPDIR environment variable unless your system prompt names a scratchpad directory — the scratchpad takes priority for temporary files. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use /tmp directly - use $TMPDIR instead.";
|
|
123
125
|
const wrapped = {
|
|
124
126
|
...def,
|
|
125
127
|
description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}\n${heredocNote}\n${tmpdirNote}`,
|
|
@@ -48,6 +48,18 @@ export declare function scratchpadDir(opts?: {
|
|
|
48
48
|
uid?: number;
|
|
49
49
|
tmp?: string;
|
|
50
50
|
}): string | null;
|
|
51
|
+
/**
|
|
52
|
+
* PURE: the per-user scratchpad OWNER ROOT, recomputed from the SAME inputs
|
|
53
|
+
* `scratchpadDir` resolves from (never derived by stripping segments off a
|
|
54
|
+
* built path — a layout change would silently shift a stripped spelling).
|
|
55
|
+
* The value the sandbox forces TMPDIR onto (Claude parity): per-user, shared
|
|
56
|
+
* across sessions, and short enough for named unix-socket binds where the
|
|
57
|
+
* deep scratchpad path would trip macOS's 104-char sun_path limit.
|
|
58
|
+
*/
|
|
59
|
+
export declare function scratchpadOwnerRootFor(opts?: {
|
|
60
|
+
uid?: number;
|
|
61
|
+
tmp?: string;
|
|
62
|
+
}): string;
|
|
51
63
|
/**
|
|
52
64
|
* IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
|
|
53
65
|
* the path on success and null on failure — a null result means "no scratchpad
|
|
@@ -56,7 +68,9 @@ export declare function scratchpadDir(opts?: {
|
|
|
56
68
|
export declare function ensureScratchpadDir(path: string, mkdir?: (p: string, o: {
|
|
57
69
|
mode: number;
|
|
58
70
|
recursive: boolean;
|
|
59
|
-
}) => void
|
|
71
|
+
}) => void, opts?: {
|
|
72
|
+
onError?: (errorCode: string) => void;
|
|
73
|
+
}): string | null;
|
|
60
74
|
/**
|
|
61
75
|
* PURE: the prompt section naming the scratchpad. Gated by the caller on the
|
|
62
76
|
* dir existing; when present, it tells the agent where to put intermediate
|
|
@@ -61,17 +61,39 @@ export function scratchpadDir(opts = {}) {
|
|
|
61
61
|
const cwd = sanitizeCwdSegment(opts.cwd ?? ".");
|
|
62
62
|
return join(tmp, scratchpadOwnerDir(uid), cwd, sessionId, "scratchpad");
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* PURE: the per-user scratchpad OWNER ROOT, recomputed from the SAME inputs
|
|
66
|
+
* `scratchpadDir` resolves from (never derived by stripping segments off a
|
|
67
|
+
* built path — a layout change would silently shift a stripped spelling).
|
|
68
|
+
* The value the sandbox forces TMPDIR onto (Claude parity): per-user, shared
|
|
69
|
+
* across sessions, and short enough for named unix-socket binds where the
|
|
70
|
+
* deep scratchpad path would trip macOS's 104-char sun_path limit.
|
|
71
|
+
*/
|
|
72
|
+
export function scratchpadOwnerRootFor(opts = {}) {
|
|
73
|
+
const tmp = opts.tmp ?? tmpdir();
|
|
74
|
+
const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() ?? 0 : 0);
|
|
75
|
+
return join(tmp, scratchpadOwnerDir(uid));
|
|
76
|
+
}
|
|
64
77
|
/**
|
|
65
78
|
* IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
|
|
66
79
|
* the path on success and null on failure — a null result means "no scratchpad
|
|
67
80
|
* this session", which the caller turns into an omitted prompt section.
|
|
68
81
|
*/
|
|
69
|
-
export function ensureScratchpadDir(path, mkdir = mkdirSync) {
|
|
82
|
+
export function ensureScratchpadDir(path, mkdir = mkdirSync, opts = {}) {
|
|
70
83
|
try {
|
|
71
84
|
mkdir(path, { recursive: true, mode: 0o700 });
|
|
72
85
|
return path;
|
|
73
86
|
}
|
|
74
|
-
catch {
|
|
87
|
+
catch (err) {
|
|
88
|
+
// The errno CODE (never the thrown message — the message can carry a
|
|
89
|
+
// path or system detail, and the caller forwards this to the sink).
|
|
90
|
+
// fs errors are plain Error instances whose distinguishing value is
|
|
91
|
+
// .code (ENOTDIR vs EACCES vs EPERM...); the constructor name would
|
|
92
|
+
// always read "Error" and distinguish nothing. The fallback keeps the
|
|
93
|
+
// class name for codeless throws (a non-fs throw), so the callback's
|
|
94
|
+
// value is "errno code, or class name when there is no code".
|
|
95
|
+
const code = err?.code;
|
|
96
|
+
opts.onError?.(code ?? (err instanceof Error ? err.constructor.name : typeof err));
|
|
75
97
|
return null;
|
|
76
98
|
}
|
|
77
99
|
}
|
|
@@ -82,12 +104,13 @@ export function ensureScratchpadDir(path, mkdir = mkdirSync) {
|
|
|
82
104
|
*/
|
|
83
105
|
export function scratchpadSection(dir) {
|
|
84
106
|
return ("# Scratchpad directory\n\n" +
|
|
85
|
-
`
|
|
107
|
+
`IMPORTANT: Always use this scratchpad directory for temporary files instead of /tmp or other system temp directories (including $TMPDIR in sandboxed bash):\n` +
|
|
86
108
|
`${dir}\n\n` +
|
|
109
|
+
"Use this directory for ALL temporary file needs:\n" +
|
|
87
110
|
"- Store intermediate results or data during multi-step tasks.\n" +
|
|
88
111
|
"- Write temporary scripts or configuration files.\n" +
|
|
89
112
|
"- Save outputs that don't belong in the user's project.\n" +
|
|
90
|
-
"-
|
|
113
|
+
"- Staging files for subagents: paths in this directory are readable by every subagent of this session, so hand subagents scratchpad paths rather than $TMPDIR or /tmp paths.\n\n" +
|
|
91
114
|
"The directory is session-specific and isolated from the user's project.");
|
|
92
115
|
}
|
|
93
116
|
//# sourceMappingURL=scratchpad.js.map
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
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.
|
|
3
|
+
"version": "1.1.4-staging.1419.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": "
|
|
61
|
+
"yagniSourceSha": "72326f4909f8b24a2657ae8ddd9caa4f4ad2e614"
|
|
62
62
|
}
|