@yagni-app/code-staging 0.3.0-staging.1061.1 → 0.3.0-staging.1067.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.
@@ -48,21 +48,33 @@ export interface ImageAttachment {
48
48
  */
49
49
  export declare function unwrapBracketedPaste(data: string): string | null;
50
50
  /**
51
- * Recognize a pasted temp-image path written by a Ghostty-based terminal
52
- * (cmux, Ghostty). Those terminals name the file `clipboard-<ts>-<id>.<ext>`
53
- * in the system temp dir. We match strictly clipboard- prefix, image
54
- * extension, real file, and genuine image magic bytes — so a path the user
55
- * typed by hand (e.g. /Users/me/photo.png) is never silently converted.
56
- * Returns the decoded image, or null when the pasted text is not one such path.
51
+ * Recognize a pasted payload that is entirely image-file paths and decode
52
+ * them. This covers Finder/desktop drag-drop and Ghostty/cmux's Cmd+V
53
+ * (which writes the clipboard image to a `clipboard-*.png` temp file and
54
+ * pastes its path) Claude Code parity: dragging a screenshot in becomes
55
+ * `[Image #N]`, not a literal path.
56
+ *
57
+ * Guardrails against converting something the user meant as text: the WHOLE
58
+ * paste must be path tokens, every token must be an absolute path to a real
59
+ * file with an image extension AND genuine image magic bytes, and the
60
+ * conversion is visible — each image becomes a chip in the prompt, so nothing
61
+ * is ever attached silently. Returns null when the paste is not such a list
62
+ * (caller passes it through as ordinary text).
57
63
  */
58
- export declare function readPastedImagePath(pastedText: string): ClipboardImage | null;
64
+ export declare function readPastedImagePaths(pastedText: string): ClipboardImage[] | null;
59
65
  /**
60
66
  * Read an image from the system clipboard without forking pi. Best-effort and
61
- * cross-platform: native module if present, else osascript/pngpaste (macOS),
67
+ * cross-platform using ONLY tools the OS ships with: osascript (macOS),
62
68
  * wl-paste (Wayland), xclip (X11), PowerShell (Windows/WSL). Returns null when
63
69
  * the clipboard holds no image (caller then pastes text instead).
64
70
  */
65
71
  export declare const defaultClipboardImageReader: ClipboardImageReader;
72
+ /**
73
+ * Parse osascript's clipboard dump — `«data PNGf89504E47…»` — into raw bytes.
74
+ * The 4-char tag after `«data ` is the pasteboard flavor; the hex that follows
75
+ * is the image. Returns null when the output holds no such dump.
76
+ */
77
+ export declare function parseOsascriptImageHex(out: string): Uint8Array | null;
66
78
  /**
67
79
  * A CustomEditor that turns clipboard image paste into numbered `[Image #N]`
68
80
  * chips. The image bytes live here, keyed by chip number; the editor text holds
@@ -64,58 +64,98 @@ const IMAGE_MAGIC = [
64
64
  [[0x47, 0x49, 0x46, 0x38], "image/gif"], // GIF8
65
65
  [[0x52, 0x49, 0x46, 0x46], "image/webp"], // RIFF (webp container)
66
66
  ];
67
+ /** A text paste this long is prose, not a dragged file list — bail early. */
68
+ const MAX_PASTED_PATHS = 20;
67
69
  /**
68
- * Recognize a pasted temp-image path written by a Ghostty-based terminal
69
- * (cmux, Ghostty). Those terminals name the file `clipboard-<ts>-<id>.<ext>`
70
- * in the system temp dir. We match strictly — clipboard- prefix, image
71
- * extension, real file, and genuine image magic bytes so a path the user
72
- * typed by hand (e.g. /Users/me/photo.png) is never silently converted.
73
- * Returns the decoded image, or null when the pasted text is not one such path.
70
+ * Split a pasted payload into path tokens the way terminals produce them:
71
+ * Finder/desktop drag-drop inserts absolute paths with backslash-escaped
72
+ * spaces (POSIX) or quote-wrapped paths (Windows Terminal), multiple files
73
+ * separated by whitespace. Returns [] when the payload can't be a path list
74
+ * (e.g. an unbalanced quote from ordinary prose like "don't").
74
75
  */
75
- export function readPastedImagePath(pastedText) {
76
- const trimmed = pastedText.trim();
77
- if (trimmed.includes("\n") || trimmed.includes(" "))
78
- return null; // one token only
79
- // Validate the RAW input first: an attacker could smuggle the `clipboard-`
80
- // prefix through backslash escapes (e.g. `\c\l\i\p\b\o\a\r\d-`), so we
81
- // must confirm the un-escaped form is a well-formed tmp image path before any
82
- // unescaping or path resolution. cmux tmp paths contain no escapable chars
83
- // (no spaces/metacharacters), so a legitimate paste has NO backslashes at all.
84
- // On Windows the backslash IS the path separator (and cmd/PowerShell do no
85
- // backslash-escaping), so the escape-smuggling rejection applies only where
86
- // a backslash could be an escape: POSIX shells.
76
+ function splitPathTokens(text) {
87
77
  const isWindows = process.platform === "win32";
88
- if (!isWindows && trimmed.includes("\\"))
89
- return null; // escapes => not a plain cmux tmp path
90
- const path = trimmed;
91
- if (isWindows ? !isAbsolute(path) : !path.startsWith("/"))
92
- return null;
93
- const name = basename(path);
94
- if (!name.startsWith("clipboard-"))
95
- return null;
96
- const ext = name.split(".").pop()?.toLowerCase() ?? "";
97
- if (!(ext in IMAGE_EXT_MIME))
98
- return null;
99
- if (!existsSync(path))
100
- return null;
101
- let bytes;
102
- try {
103
- bytes = readFileSync(path);
104
- }
105
- catch {
106
- return null;
78
+ const tokens = [];
79
+ let cur = "";
80
+ let quote = null;
81
+ for (let i = 0; i < text.length; i++) {
82
+ const ch = text[i];
83
+ if (quote) {
84
+ if (ch === quote)
85
+ quote = null;
86
+ else
87
+ cur += ch;
88
+ continue;
89
+ }
90
+ if (ch === '"' || ch === "'") {
91
+ quote = ch;
92
+ continue;
93
+ }
94
+ // POSIX shells escape spaces/metacharacters with a backslash; on Windows
95
+ // the backslash IS the path separator, so no unescaping there.
96
+ if (!isWindows && ch === "\\" && i + 1 < text.length) {
97
+ cur += text[++i];
98
+ continue;
99
+ }
100
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
101
+ if (cur)
102
+ tokens.push(cur);
103
+ cur = "";
104
+ continue;
105
+ }
106
+ cur += ch;
107
107
  }
108
- if (bytes.length === 0)
108
+ if (quote !== null)
109
+ return [];
110
+ if (cur)
111
+ tokens.push(cur);
112
+ return tokens;
113
+ }
114
+ /**
115
+ * Recognize a pasted payload that is entirely image-file paths and decode
116
+ * them. This covers Finder/desktop drag-drop and Ghostty/cmux's Cmd+V
117
+ * (which writes the clipboard image to a `clipboard-*.png` temp file and
118
+ * pastes its path) — Claude Code parity: dragging a screenshot in becomes
119
+ * `[Image #N]`, not a literal path.
120
+ *
121
+ * Guardrails against converting something the user meant as text: the WHOLE
122
+ * paste must be path tokens, every token must be an absolute path to a real
123
+ * file with an image extension AND genuine image magic bytes, and the
124
+ * conversion is visible — each image becomes a chip in the prompt, so nothing
125
+ * is ever attached silently. Returns null when the paste is not such a list
126
+ * (caller passes it through as ordinary text).
127
+ */
128
+ export function readPastedImagePaths(pastedText) {
129
+ const tokens = splitPathTokens(pastedText.trim());
130
+ if (tokens.length === 0 || tokens.length > MAX_PASTED_PATHS)
109
131
  return null;
110
- for (const [magic, mime] of IMAGE_MAGIC) {
111
- if (magic.every((b, i) => bytes[i] === b))
112
- return { bytes, mimeType: mime };
132
+ const isWindows = process.platform === "win32";
133
+ const images = [];
134
+ for (const path of tokens) {
135
+ if (isWindows ? !isAbsolute(path) : !path.startsWith("/"))
136
+ return null;
137
+ const ext = basename(path).split(".").pop()?.toLowerCase() ?? "";
138
+ if (!(ext in IMAGE_EXT_MIME))
139
+ return null;
140
+ if (!existsSync(path))
141
+ return null;
142
+ let bytes;
143
+ try {
144
+ bytes = readFileSync(path);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ const magic = IMAGE_MAGIC.find(([m]) => m.every((b, i) => bytes[i] === b));
150
+ if (!magic)
151
+ return null;
152
+ images.push({ bytes, mimeType: magic[1] });
113
153
  }
114
- return null;
154
+ return images;
115
155
  }
116
156
  /**
117
157
  * Read an image from the system clipboard without forking pi. Best-effort and
118
- * cross-platform: native module if present, else osascript/pngpaste (macOS),
158
+ * cross-platform using ONLY tools the OS ships with: osascript (macOS),
119
159
  * wl-paste (Wayland), xclip (X11), PowerShell (Windows/WSL). Returns null when
120
160
  * the clipboard holds no image (caller then pastes text instead).
121
161
  */
@@ -145,7 +185,45 @@ function runBase64(command, args, timeoutMs = 3000) {
145
185
  return out.length > 0 ? out : null;
146
186
  }
147
187
  function readMacClipboardImage() {
148
- // pngpaste writes the clipboard image to a PNG file (cleanest on macOS).
188
+ return readMacClipboardViaOsascript() ?? readMacClipboardViaPngpaste();
189
+ }
190
+ /**
191
+ * Parse osascript's clipboard dump — `«data PNGf89504E47…»` — into raw bytes.
192
+ * The 4-char tag after `«data ` is the pasteboard flavor; the hex that follows
193
+ * is the image. Returns null when the output holds no such dump.
194
+ */
195
+ export function parseOsascriptImageHex(out) {
196
+ const m = /«data \w{4}((?:[0-9A-Fa-f]{2})+)»/.exec(out);
197
+ if (!m)
198
+ return null;
199
+ const bytes = Buffer.from(m[1], "hex");
200
+ return bytes.length > 0 ? bytes : null;
201
+ }
202
+ function readMacClipboardViaOsascript() {
203
+ // AppleScript ships with macOS, so this route needs no install (it is how
204
+ // Claude Code reads clipboard images too). Screenshots and browser
205
+ // "Copy image" put a PNG flavor on the pasteboard; try JPEG second.
206
+ for (const [cls, mime] of [
207
+ ["PNGf", "image/png"],
208
+ ["JPEG", "image/jpeg"],
209
+ ]) {
210
+ const res = spawnSync("osascript", ["-e", `the clipboard as «class ${cls}»`], {
211
+ timeout: 5000,
212
+ maxBuffer: 256 * 1024 * 1024, // the hex dump doubles the image size
213
+ });
214
+ if (res.error || res.status !== 0)
215
+ continue;
216
+ const out = Buffer.isBuffer(res.stdout) ? res.stdout.toString("utf8") : String(res.stdout ?? "");
217
+ const bytes = parseOsascriptImageHex(out);
218
+ if (bytes)
219
+ return { bytes, mimeType: mime };
220
+ }
221
+ return null;
222
+ }
223
+ function readMacClipboardViaPngpaste() {
224
+ // Optional fallback for pasteboard flavors osascript can't coerce to
225
+ // PNG/JPEG (e.g. TIFF-only sources) — pngpaste converts anything to PNG.
226
+ // Nice when installed, never required.
149
227
  const dest = join(tmpdir(), `yagni-clip-${randomUUID()}.png`);
150
228
  try {
151
229
  const res = spawnSync("pngpaste", [dest], { timeout: 3000 });
@@ -222,27 +300,31 @@ export class ChipEditor extends CustomEditor {
222
300
  * Kitty-protocol terminals that genuinely deliver the Cmd modifier.
223
301
  */
224
302
  handleInput(data) {
225
- // Cmd+V on Ghostty-based terminals (cmux, Ghostty): the terminal writes the
226
- // clipboard image to a temp file and pastes its PATH as bracketed text, so
227
- // the payload arrives here as "\x1b[200~<path>\x1b[201~" (pi's Terminal
228
- // re-wraps it). handlePaste is private and consumed inside super.handleInput,
229
- // so we strip the markers here and convert a bare image path to a chip.
303
+ // Pasted image PATHS arrive as bracketed text ("\x1b[200~<paths>\x1b[201~",
304
+ // pi's Terminal re-wraps every paste): Finder/desktop drag-drop inserts the
305
+ // file's path, and Ghostty-based terminals (cmux, Ghostty) write a Cmd+V'd
306
+ // clipboard image to a temp file and paste ITS path. handlePaste is private
307
+ // and consumed inside super.handleInput, so we strip the markers here and
308
+ // convert an all-image-paths payload into chips.
230
309
  const unwrapped = unwrapBracketedPaste(data);
231
310
  if (unwrapped !== null) {
232
- const image = readPastedImagePath(unwrapped);
311
+ const images = readPastedImagePaths(unwrapped);
233
312
  logImagePaste({
234
313
  event: "paste_path",
235
- outcome: image ? "ok" : "passthrough",
236
- mimeType: image?.mimeType,
237
- bytes: image?.bytes.length,
238
- file: image ? unwrapped : undefined,
239
- detail: image ? undefined : `not a lone image path: ${unwrapped.slice(0, 60)}`,
314
+ outcome: images ? "ok" : "passthrough",
315
+ imageCount: images?.length,
316
+ bytes: images?.reduce((sum, i) => sum + i.bytes.length, 0),
317
+ detail: images ? undefined : `not an image path list: ${unwrapped.slice(0, 60)}`,
240
318
  });
241
- if (image) {
242
- this.dropChip(image.bytes, image.mimeType);
319
+ if (images) {
320
+ images.forEach((image, i) => {
321
+ if (i > 0)
322
+ this.insertTextAtCursor(" ");
323
+ this.dropChip(image.bytes, image.mimeType);
324
+ });
243
325
  return;
244
326
  }
245
- // Not a lone image path — re-wrap and let pi handle the paste normally.
327
+ // Not a list of image paths — re-wrap and let pi handle the paste normally.
246
328
  super.handleInput(data);
247
329
  return;
248
330
  }
@@ -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,
@@ -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
  /**
@@ -30,7 +30,7 @@ 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
@@ -435,7 +448,7 @@ export async function registerYagni(pi, deps = {}) {
435
448
  // renders. The message text deliberately matches neither pi's overflow nor
436
449
  // retryable-error patterns: a deterministic empty response should not burn
437
450
  // auto-retries or trigger compaction — the user decides what to do next.
438
- pi.on("message_end", (event, ctx) => {
451
+ pi.on("message_end", async (event, ctx) => {
439
452
  const msg = event.message;
440
453
  if (msg.role !== "assistant")
441
454
  return;
@@ -450,6 +463,71 @@ export async function registerYagni(pi, deps = {}) {
450
463
  // post-replacement message, and dropping the marker would defeat the very
451
464
  // recovery this error exists to trigger.
452
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.
453
531
  const isContextTooLarge = /request_too_large|context_too_large/i.test(msg.errorMessage);
454
532
  if (!isContextTooLarge)
455
533
  return;
@@ -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.1061.1",
3
+ "version": "0.3.0-staging.1067.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": "44711af698e076c2e718fdd813f4e8ab4b875a0a"
41
+ "yagniSourceSha": "30603566bf16560437d0729158a147e177426f01"
42
42
  }