@yagni-app/code-staging 0.3.0-staging.1059.1 → 0.3.0-staging.1064.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.
@@ -125,8 +125,6 @@ export function makeCrashReporter(opts) {
125
125
  if (crashReportsDisabled(env))
126
126
  return;
127
127
  const token = opts.getToken();
128
- if (!token)
129
- return;
130
128
  const sanitized = sanitizeCrashError(error, { env, repoRoot });
131
129
  const payload = {
132
130
  client: isDesktopSurface() ? "desktop" : "cli",
@@ -148,7 +146,7 @@ export function makeCrashReporter(opts) {
148
146
  method: "POST",
149
147
  headers: {
150
148
  "content-type": "application/json",
151
- authorization: `Bearer ${token}`,
149
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
152
150
  },
153
151
  body: JSON.stringify(payload),
154
152
  signal: controller.signal,
@@ -39,6 +39,7 @@
39
39
  * — useful reference for what data to replicate and how to format it.
40
40
  */
41
41
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
+ import type { ModeHolder, PermissionMode } from "./permission.js";
42
43
  export declare const BRANCH_MAX_WIDTH = 60;
43
44
  /**
44
45
  * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
@@ -90,6 +91,8 @@ export declare function detectGitInfo(cwd: string, home: string | undefined): Gi
90
91
  export declare function renderFooterLines(input: {
91
92
  git: GitInfo;
92
93
  model: string;
94
+ /** Current permission mode; shown on line 2 to the left of the model as "<mode> mode". */
95
+ mode?: PermissionMode | null;
93
96
  usage: UsageTotals;
94
97
  contextPercent: number | null;
95
98
  statuses: string[];
@@ -99,7 +102,7 @@ export declare function renderFooterLines(input: {
99
102
  * and returns the component `setFooter` expects. Called from the
100
103
  * `session_start` handler in index.ts.
101
104
  */
102
- export declare function createYagniFooterFactory(ctx: ExtensionContext): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
105
+ export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
103
106
  render(width: number): string[];
104
107
  invalidate(): void;
105
108
  dispose(): void;
@@ -221,7 +221,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
221
221
  line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
222
222
  }
223
223
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
224
- // Line 2: model · ↑in ↓out $cost · ctx%
224
+ // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
225
225
  const statParts = [];
226
226
  if (input.usage.input)
227
227
  statParts.push(`↑${formatTokens(input.usage.input)}`);
@@ -231,7 +231,10 @@ export function renderFooterLines(input, theme, width, padX = 0) {
231
231
  statParts.push(`$${input.usage.cost.toFixed(3)}`);
232
232
  const stats = statParts.join(" ");
233
233
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
234
- const line2Parts = [dim(input.model)];
234
+ const line2Parts = [];
235
+ if (input.mode)
236
+ line2Parts.push(dim(`${input.mode} mode`));
237
+ line2Parts.push(dim(input.model));
235
238
  if (stats)
236
239
  line2Parts.push(dim(stats));
237
240
  line2Parts.push(theme.fg(contextColor(input.contextPercent), percentText));
@@ -249,7 +252,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
249
252
  * and returns the component `setFooter` expects. Called from the
250
253
  * `session_start` handler in index.ts.
251
254
  */
252
- export function createYagniFooterFactory(ctx) {
255
+ export function createYagniFooterFactory(ctx, modeHolder) {
253
256
  return (_tui, theme, footerData) => {
254
257
  // Recompute git/worktree info only when the branch actually changes. Optional-
255
258
  // chained: onBranchChange is typed on ReadonlyFooterDataProvider, but the runtime
@@ -273,6 +276,7 @@ export function createYagniFooterFactory(ctx) {
273
276
  return renderFooterLines({
274
277
  git: gitInfo(),
275
278
  model: ctx.model?.id ?? "no-model",
279
+ mode: modeHolder?.get() ?? null,
276
280
  usage: collectUsage(ctx.sessionManager),
277
281
  contextPercent: ctx.getContextUsage()?.percent ?? null,
278
282
  statuses,
@@ -63,6 +63,12 @@ export interface RegisterYagniDeps {
63
63
  tokenProvider?: TokenProvider;
64
64
  /** The spool flush (R4 write half), injectable so tests never touch disk. */
65
65
  flushSpool?: (opts: SpoolClientOpts) => Promise<FlushOutcome>;
66
+ /**
67
+ * Non-fatal auth-event reporter (YAG-500 Fix E). Defaults to
68
+ * `makeCrashReporter` gated on `!evalMode`; inject a spy in tests to assert
69
+ * the report is fired with `context: "auth-failure"` and the refresh outcome.
70
+ */
71
+ authReporter?: (error: unknown, context?: string) => Promise<void>;
66
72
  env?: NodeJS.ProcessEnv;
67
73
  }
68
74
  /**
@@ -23,14 +23,14 @@ import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone }
23
23
  import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
24
24
  import { registerGoCommand } from "./pipeline/goCommand.js";
25
25
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
26
- import { DEFAULT_PERMISSION_POLICY, registerPermissionGate } from "./permission.js";
26
+ import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
27
27
  import { registerSubagents } from "./subagents.js";
28
28
  import { registerTodos } from "./todos.js";
29
29
  import { registerDecisionCommands } from "./decisions.js";
30
30
  import { makeDecisionCapture } from "./decisionCapture.js";
31
31
  import { registerAmbientRecall } from "./recall.js";
32
32
  import { resilientFetch } from "./resilientFetch.js";
33
- import { installUncaughtExceptionMonitor } from "./crashReport.js";
33
+ import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
34
34
  import { flushSpool as defaultFlushSpool } from "./spool.js";
35
35
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
36
36
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
@@ -113,6 +113,19 @@ export async function registerYagni(pi, deps = {}) {
113
113
  if (!evalMode) {
114
114
  installUncaughtExceptionMonitor({ baseUrl, getToken: getTokenFn, env: deps.env });
115
115
  }
116
+ // YAG-500 Fix E: non-fatal auth-event reporter for 401s on the model path.
117
+ // Reuses the crash endpoint (/api/yagni-code/crash) with a distinct context
118
+ // so auth failures are visible in Sentry/backend logs. Gated on !evalMode
119
+ // like every other external side effect; injectable for tests.
120
+ const authReporter = deps.authReporter ??
121
+ (!evalMode
122
+ ? makeCrashReporter({ baseUrl, getToken: getTokenFn, fetchImpl: deps.fetchImpl, env: deps.env })
123
+ : async () => { });
124
+ // YAG-500 Fix A+C: the model-path 401 recovery outcome, set by the
125
+ // message_end handler so it can produce the right user-facing message. The
126
+ // after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
127
+ // before onResponse is reached), so message_end is the only seam.
128
+ let lastAuthRecovery = null;
116
129
  const fullCatalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
117
130
  // Lock the interactive session to the `advanced` tier only. The backend
118
131
  // catalog returns all tiers, but only `advanced` is registered with the
@@ -220,7 +233,9 @@ export async function registerYagni(pi, deps = {}) {
220
233
  // drafts a decision on "don't ask again". Default auto, so still additive.
221
234
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
222
235
  // holds them, review mode confirms them.
236
+ const modeHolder = createModeHolder();
223
237
  registerPermissionGate(pi, {
238
+ modeHolder,
224
239
  ...(mcpMutatingTools.length > 0
225
240
  ? {
226
241
  policy: {
@@ -433,7 +448,7 @@ export async function registerYagni(pi, deps = {}) {
433
448
  // renders. The message text deliberately matches neither pi's overflow nor
434
449
  // retryable-error patterns: a deterministic empty response should not burn
435
450
  // auto-retries or trigger compaction — the user decides what to do next.
436
- pi.on("message_end", (event, ctx) => {
451
+ pi.on("message_end", async (event, ctx) => {
437
452
  const msg = event.message;
438
453
  if (msg.role !== "assistant")
439
454
  return;
@@ -448,6 +463,71 @@ export async function registerYagni(pi, deps = {}) {
448
463
  // post-replacement message, and dropping the marker would defeat the very
449
464
  // recovery this error exists to trigger.
450
465
  if (msg.stopReason === "error" && msg.errorMessage) {
466
+ // YAG-500: a 401 from the model proxy means the session token expired
467
+ // (or was revoked). Unlike tool 401s (handled by makeAuthedFetch), the
468
+ // model completion path has no 401-retry seam — pi's retryProviderRequest
469
+ // treats 401 as non-retryable, and after_provider_response never fires
470
+ // (the SDK throws before onResponse is reached). So message_end is the
471
+ // only place to detect it and trigger recovery. The regex matches
472
+ // "yagni login" (the backend's auth-error message) but NOT
473
+ // "request_too_large" (YAG-460's overflow marker).
474
+ const isAuthError = /yagni login/i.test(msg.errorMessage)
475
+ && !/request_too_large|context_too_large/i.test(msg.errorMessage);
476
+ if (isAuthError) {
477
+ lastAuthRecovery = null;
478
+ let rotated = false;
479
+ try {
480
+ rotated = await tokenProvider.refresh();
481
+ }
482
+ catch {
483
+ rotated = false;
484
+ }
485
+ lastAuthRecovery = rotated ? "refreshed" : "failed";
486
+ const explanation = rotated
487
+ ? "Your session token expired but was refreshed automatically. Re-send your prompt to continue."
488
+ : "Your session token expired and could not be refreshed. Run `yagni login`, then re-send your prompt. If the issue persists, restart YAGNI Code.";
489
+ if (ctx.hasUI) {
490
+ try {
491
+ ctx.ui.notify(explanation, rotated ? "info" : "error");
492
+ }
493
+ catch {
494
+ // Surfacing the problem must never break the session itself.
495
+ }
496
+ }
497
+ // YAG-500 Fix E: fire a non-fatal crash report so auth failures are
498
+ // visible in Sentry/backend logs. Reuses the crash endpoint with a
499
+ // distinct context. Fire-and-forget, fail-soft. The crash endpoint is
500
+ // now public (no token required), so the report lands even when the
501
+ // session token is expired and refresh failed — the most critical
502
+ // failure signal is no longer silently dropped.
503
+ void authReporter(new Error(`auth_401 on model path; refresh=${rotated ? "succeeded" : "failed"}`), "auth-failure").catch(() => { });
504
+ // YAG-500 Fix F: local diagnostics log under YAGNI_DEBUG.
505
+ if (isDebug(env)) {
506
+ try {
507
+ const logPath = join(codeStateHome(null, env), "logs", "auth-events.log");
508
+ mkdirSync(dirname(logPath), { recursive: true });
509
+ appendFileSync(logPath, JSON.stringify({
510
+ ts: new Date().toISOString(),
511
+ status: 401,
512
+ refresh: rotated ? "succeeded" : "failed",
513
+ }) + "\n", "utf8");
514
+ }
515
+ catch {
516
+ // A diagnostic must never break the session.
517
+ }
518
+ }
519
+ return { message: { ...msg, errorMessage: explanation } };
520
+ }
521
+ // YAG-460: the backend proxy answers an oversized conversation with an
522
+ // OpenAI-format 413 whose type is `request_too_large`. pi's own overflow
523
+ // detection matches that marker and runs full recovery — compact, then
524
+ // auto-retry the failed turn — so this branch must NOT call ctx.compact()
525
+ // (it would race the built-in recovery and lose the retry). Its only job
526
+ // is UX: replace the raw `413: {"error":{...}}` JSON with a readable
527
+ // message and tell the user what is happening. The rewritten text KEEPS
528
+ // the `request_too_large` marker verbatim: pi's _checkCompaction reads the
529
+ // post-replacement message, and dropping the marker would defeat the very
530
+ // recovery this error exists to trigger.
451
531
  const isContextTooLarge = /request_too_large|context_too_large/i.test(msg.errorMessage);
452
532
  if (!isContextTooLarge)
453
533
  return;
@@ -504,7 +584,7 @@ export async function registerYagni(pi, deps = {}) {
504
584
  // context % on line 2, and extension statuses (brand, todos, mode) on
505
585
  // line 3. The factory captures ctx so the footer can read session data
506
586
  // (token stats, context usage) that isn't on the footerData provider.
507
- ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx)(tui, theme, footerData));
587
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder)(tui, theme, footerData));
508
588
  }
509
589
  // Best-effort, once at session start: if the token is at or near expiry, say
510
590
  // so via a single notice so a long session does not silently start 401-ing
@@ -29,6 +29,17 @@
29
29
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
30
  import { type BlessStore } from "./bless.js";
31
31
  export type PermissionMode = "auto" | "plan" | "review";
32
+ /**
33
+ * A shared, mutable holder for the current permission mode. Both
34
+ * `registerPermissionGate` (which writes on /mode) and the footer factory
35
+ * (which reads on every render) hold a reference, so mode changes appear
36
+ * immediately in the footer's line 2.
37
+ */
38
+ export interface ModeHolder {
39
+ get(): PermissionMode;
40
+ set(m: PermissionMode): void;
41
+ }
42
+ export declare function createModeHolder(initial?: PermissionMode): ModeHolder;
32
43
  /** Which tools each tier acts on, plus the optional grounding-bless predicate. */
33
44
  export interface PermissionPolicy {
34
45
  /** Tools blocked outright in plan mode (write/exec). */
@@ -76,6 +87,8 @@ export interface RegisterPermissionDeps {
76
87
  * never blocks the approved tool call.
77
88
  */
78
89
  onBlessRemember?: (ctx: ExtensionContext, info: BlessRememberInfo) => void | Promise<void>;
90
+ /** Shared holder so the footer can read the live mode on every render. */
91
+ modeHolder?: ModeHolder;
79
92
  }
80
93
  /** The customType tag on injected plan-mode context (filterable later). */
81
94
  export declare const PLAN_CONTEXT_TYPE = "yagni-plan-context";
@@ -27,6 +27,13 @@
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
29
  import { makeBlessStore as defaultMakeBlessStore } from "./bless.js";
30
+ export function createModeHolder(initial = "auto") {
31
+ let current = initial;
32
+ return {
33
+ get: () => current,
34
+ set: (m) => { current = m; },
35
+ };
36
+ }
30
37
  export const DEFAULT_PERMISSION_POLICY = {
31
38
  planBlockTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
32
39
  reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
@@ -232,6 +239,7 @@ export function registerPermissionGate(pi, deps = {}) {
232
239
  return { messages: filtered };
233
240
  });
234
241
  const paintMode = (ctx) => {
242
+ deps.modeHolder?.set(mode);
235
243
  try {
236
244
  if (ctx.hasUI)
237
245
  ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
@@ -130,7 +130,7 @@ export function makeTokenProvider(deps) {
130
130
  });
131
131
  }, delay);
132
132
  }
133
- function applyRotation(rotation) {
133
+ function applyRotation(rotation, skipPersist = false) {
134
134
  token = rotation.token;
135
135
  if (rotation.expiresAt)
136
136
  expiresAt = rotation.expiresAt;
@@ -139,13 +139,45 @@ export function makeTokenProvider(deps) {
139
139
  env.YAGNI_TOKEN = rotation.token;
140
140
  if (rotation.expiresAt)
141
141
  env.YAGNI_TOKEN_EXPIRES_AT = rotation.expiresAt;
142
+ if (!skipPersist) {
143
+ try {
144
+ persistProfile(rotation);
145
+ }
146
+ catch {
147
+ /* fail-soft */
148
+ }
149
+ }
150
+ armProactiveTimer();
151
+ }
152
+ /**
153
+ * Read the token from the launcher's profile file on disk. Used as a fallback
154
+ * when the server-side refresh fails (the old token is also invalid for
155
+ * /auth/refresh): an external `yagni login` writes a fresh token to the same
156
+ * file, so re-reading it can recover a session that the server refresh cannot.
157
+ * Returns null when the file is missing, unreadable, or carries the same token
158
+ * already in memory.
159
+ */
160
+ function readTokenFromDisk() {
161
+ const profilePath = env.YAGNI_PROFILE_PATH?.trim();
162
+ if (!profilePath)
163
+ return null;
142
164
  try {
143
- persistProfile(rotation);
165
+ const parsed = JSON.parse(readFileSync(profilePath, "utf8"));
166
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
167
+ return null;
168
+ const obj = parsed;
169
+ const diskToken = typeof obj.token === "string" ? obj.token : undefined;
170
+ if (!diskToken || diskToken === token)
171
+ return null;
172
+ return {
173
+ token: diskToken,
174
+ expiresAt: typeof obj.expiresAt === "string" ? obj.expiresAt : undefined,
175
+ workspaceId: typeof obj.workspaceId === "string" ? obj.workspaceId : undefined,
176
+ };
144
177
  }
145
178
  catch {
146
- /* fail-soft */
179
+ return null;
147
180
  }
148
- armProactiveTimer();
149
181
  }
150
182
  async function doRefresh() {
151
183
  const current = token;
@@ -161,8 +193,17 @@ export function makeTokenProvider(deps) {
161
193
  body: "{}",
162
194
  signal: AbortSignal.timeout(REFRESH_REQUEST_TIMEOUT_MS),
163
195
  });
164
- if (!res.ok)
196
+ if (!res.ok) {
197
+ // Server refresh failed (the old token is also invalid for /auth/refresh).
198
+ // Fall back to the profile file on disk: an external `yagni login` may
199
+ // have written a fresh token there that this running session hasn't seen.
200
+ const disk = readTokenFromDisk();
201
+ if (disk) {
202
+ applyRotation(disk, true);
203
+ return true;
204
+ }
165
205
  return false;
206
+ }
166
207
  const data = (await res.json());
167
208
  if (!data || typeof data.token !== "string" || data.token.length === 0)
168
209
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1059.1",
3
+ "version": "0.3.0-staging.1064.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.84.1",
39
39
  "typebox": "^1.3.11"
40
40
  },
41
- "yagniSourceSha": "5ccc70e2a48d1bc9b937a412ff26897522c23eee"
41
+ "yagniSourceSha": "e38a99de02a31e45c61d006943113caf8817e0bd"
42
42
  }