omnirush 0.5.1 → 0.5.3

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.
@@ -301,6 +301,66 @@ export async function pollForToken(origin, {
301
301
  }
302
302
  }
303
303
 
304
+ // --- cross-process refresh coordination (issue B) --------------------------
305
+ //
306
+ // Several omnirush processes on one machine share ~/.omnirush/auth.json.
307
+ // When the access token expires, every process sees the same 401 and
308
+ // would refresh with the SAME refresh token; the first winner ROTATES
309
+ // both tokens, the others' attempts then fail with a rejected refresh
310
+ // token and they dead-end telling the (still signed-in!) user to log in
311
+ // again. The refresher therefore: adopts newer on-disk tokens before
312
+ // refreshing, serializes actual refreshes behind an exclusive lockfile,
313
+ // and re-reads once more before declaring the session dead.
314
+
315
+ const REFRESH_LOCK_FILE = ".refresh-lock";
316
+ const REFRESH_LOCK_STALE_MS = 30_000;
317
+ const REFRESH_LOCK_WAIT_MS = 35_000;
318
+ const REFRESH_LOCK_POLL_MS = 200;
319
+
320
+ /**
321
+ * Take the exclusive refresh lock (O_EXCL create). A lock older than
322
+ * REFRESH_LOCK_STALE_MS is crashed-process debris and is taken over.
323
+ * Resolves true when WE hold the lock, false when it could not be
324
+ * acquired within the wait budget (caller proceeds unlocked — an extra
325
+ * rejected refresh is recoverable, a hang is not).
326
+ */
327
+ async function acquireRefreshLock(dir, sleep) {
328
+ const lockPath = path.join(dir, REFRESH_LOCK_FILE);
329
+ const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
330
+ for (;;) {
331
+ try {
332
+ const fd = fs.openSync(lockPath, "wx", 0o600);
333
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
334
+ fs.closeSync(fd);
335
+ return true;
336
+ } catch (error) {
337
+ if (error?.code !== "EEXIST") return false;
338
+ try {
339
+ const stats = fs.statSync(lockPath);
340
+ if (Date.now() - stats.mtimeMs > REFRESH_LOCK_STALE_MS) {
341
+ fs.rmSync(lockPath, { force: true });
342
+ continue;
343
+ }
344
+ } catch {
345
+ continue; // vanished — retry the create
346
+ }
347
+ }
348
+ if (Date.now() >= deadline) return false;
349
+ await sleep(REFRESH_LOCK_POLL_MS);
350
+ }
351
+ }
352
+
353
+ /** Release the refresh lock, but only if we still own it. */
354
+ function releaseRefreshLock(dir) {
355
+ const lockPath = path.join(dir, REFRESH_LOCK_FILE);
356
+ try {
357
+ const content = fs.readFileSync(lockPath, "utf8");
358
+ if (content.includes(String(process.pid))) fs.rmSync(lockPath, { force: true });
359
+ } catch {
360
+ /* gone already */
361
+ }
362
+ }
363
+
304
364
  export class RefreshRejectedError extends Error {
305
365
  constructor(detail) {
306
366
  super(`refresh rejected: ${detail}`);
@@ -398,20 +458,51 @@ export function createRefresher({ dir, origin, fetchImpl, now = () => Date.now()
398
458
 
399
459
  const performRefresh = async (expectedAccessToken) => {
400
460
  if (!auth || auth.accessToken !== expectedAccessToken) return true;
401
- let next;
461
+ // 1. Cross-process adoption: another process may have refreshed
462
+ // already — the on-disk access token would differ from ours. Adopt
463
+ // it and let the caller retry; no refresh call, no rotation race.
464
+ const onDiskBefore = dir ? loadAuth(dir) : null;
465
+ if (onDiskBefore && onDiskBefore.accessToken && onDiskBefore.accessToken !== expectedAccessToken) {
466
+ setAuth(onDiskBefore);
467
+ return true;
468
+ }
469
+ // 2. Serialize the actual refresh across processes: re-read under
470
+ // the lock (the winner may have finished while we waited for it).
471
+ const haveLock = dir
472
+ ? await acquireRefreshLock(dir, (ms) => new Promise((resolve) => setTimeout(resolve, ms)))
473
+ : false;
402
474
  try {
403
- next = await refreshTokens(origin, { refreshToken: auth.refreshToken, fetchImpl: doFetch });
404
- } catch (error) {
405
- if (error instanceof RefreshRejectedError) {
406
- // Dead refresh token: caller surfaces a re-login hint. Keep the
407
- // stored pair so doctor can still show an explicit invalid state.
408
- return false;
475
+ const underLock = dir ? loadAuth(dir) : null;
476
+ if (underLock && underLock.accessToken && underLock.accessToken !== expectedAccessToken) {
477
+ setAuth(underLock);
478
+ return true;
479
+ }
480
+ const attemptRefreshToken = underLock?.refreshToken ?? auth.refreshToken;
481
+ let next;
482
+ try {
483
+ next = await refreshTokens(origin, { refreshToken: attemptRefreshToken, fetchImpl: doFetch });
484
+ } catch (error) {
485
+ if (error instanceof RefreshRejectedError) {
486
+ // 3. Our refresh token was rejected — but the winner may have
487
+ // written its rotated pair after our attempt started. Adopt if
488
+ // the on-disk refresh token differs from the one we tried;
489
+ // otherwise the session is genuinely dead: caller surfaces a
490
+ // re-login hint (the stored pair stays for doctor).
491
+ const latest = dir ? loadAuth(dir) : null;
492
+ if (latest && latest.refreshToken && latest.refreshToken !== attemptRefreshToken) {
493
+ setAuth(latest);
494
+ return true;
495
+ }
496
+ return false;
497
+ }
498
+ throw error;
409
499
  }
410
- throw error;
500
+ setAuth(next);
501
+ await persist(next);
502
+ return true;
503
+ } finally {
504
+ if (haveLock) releaseRefreshLock(dir);
411
505
  }
412
- setAuth(next);
413
- await persist(next);
414
- return true;
415
506
  };
416
507
 
417
508
  return {
@@ -46,7 +46,7 @@
46
46
 
47
47
  import { createHash } from "node:crypto";
48
48
  import { execFile } from "node:child_process";
49
- import { watch, type FSWatcher } from "node:fs";
49
+
50
50
  import { lstat, mkdir, readFile, readdir, readlink, writeFile } from "node:fs/promises";
51
51
  import { dirname, join, relative, resolve, sep } from "node:path";
52
52
  import { promisify } from "node:util";
@@ -166,6 +166,12 @@ export type SessionLedgerRecord = {
166
166
  nextSequence: number;
167
167
  sentBytes?: number;
168
168
  lastMessageId?: string;
169
+ /** True once the session's full-tree start baseline shipped. A resumed
170
+ * session must NOT re-upload it — a restart-heavy session would
171
+ * otherwise re-burn the entire baseline (and starve traces) on every
172
+ * restart. Observed live: 846 start snapshots / multiple GB for one
173
+ * session. */
174
+ baselineUploaded?: boolean;
169
175
  lastSeenAt: string;
170
176
  /** Per-session-FILE transcript capture state (issue: trace format spec).
171
177
  * Keys are absolute .jsonl paths; a size/mtime change re-captures. */
@@ -190,14 +196,22 @@ type SessionState = {
190
196
  started: boolean;
191
197
  finished: boolean;
192
198
  budgetExhausted: boolean;
199
+ baselineUploaded: boolean;
193
200
  changeTimer: ReturnType<typeof setTimeout> | null;
194
201
  scanTimer: ReturnType<typeof setInterval> | null;
195
- watcher: FSWatcher | null;
196
- /** Wall-clock instant the fs.watch baseline begins. Present-records
197
- * whose mtime is not after this are pre-baseline activity already
198
- * reflected in the start snapshot (macOS FSEvents delivers events for
199
- * files created just before watch() began — inotify does not). */
200
- watcherStartedAtMs: number;
202
+ /** Known per-path stat snapshot (mtimeMs + size) for change detection.
203
+ * fs.watch is deliberately NOT used: its recursive handle keeps the
204
+ * event loop alive even after close()/unref() on every runtime, which
205
+ * hung sessions at exit (issue: process never exits after the reply).
206
+ * The scan loop diffs these snapshots instead. */
207
+ knownStats: Map<string, { mtimeMs: number; size: number }>;
208
+ /** True while a change scan is in flight (scans self-serialize). */
209
+ scanning: boolean;
210
+ /** Set once the per-path stat snapshot has been seeded (post-baseline
211
+ * walk) — the scan gate, deliberately independent of the start
212
+ * UPLOAD succeeding so capture keeps working when the backend is
213
+ * down (that is when the journal must absorb changes). */
214
+ baselineSeeded: boolean;
201
215
  /** Paths the session knows exist(ed): the baseline walk plus every
202
216
  * captured present record. Deletion records require membership —
203
217
  * macOS FSEvents leaks parent-directory events (e.g. the previous
@@ -1013,6 +1027,7 @@ export class WorkspaceCollector {
1013
1027
  state.sequence = previous?.nextSequence ?? 0;
1014
1028
  state.sentBytes = previous?.sentBytes ?? 0;
1015
1029
  state.lastMessageId = previous?.lastMessageId;
1030
+ state.baselineUploaded = previous?.baselineUploaded === true;
1016
1031
  state.resumed = Boolean(previous);
1017
1032
  if (previous?.traces && typeof previous.traces === "object") {
1018
1033
  state.transcripts = { ...previous.traces };
@@ -1029,6 +1044,7 @@ export class WorkspaceCollector {
1029
1044
  nextSequence: state.sequence,
1030
1045
  sentBytes: state.sentBytes,
1031
1046
  ...(state.lastMessageId ? { lastMessageId: state.lastMessageId } : {}),
1047
+ ...(state.baselineUploaded ? { baselineUploaded: true } : {}),
1032
1048
  lastSeenAt: new Date().toISOString(),
1033
1049
  };
1034
1050
  await this.saveLedger();
@@ -1041,6 +1057,7 @@ export class WorkspaceCollector {
1041
1057
  nextSequence: state.sequence,
1042
1058
  sentBytes: state.sentBytes,
1043
1059
  ...(state.lastMessageId ? { lastMessageId: state.lastMessageId } : {}),
1060
+ ...(state.baselineUploaded ? { baselineUploaded: true } : {}),
1044
1061
  ...(Object.keys(state.transcripts).length > 0 ? { traces: state.transcripts } : {}),
1045
1062
  lastSeenAt: new Date().toISOString(),
1046
1063
  };
@@ -1080,10 +1097,12 @@ export class WorkspaceCollector {
1080
1097
  started: false,
1081
1098
  finished: false,
1082
1099
  budgetExhausted: false,
1100
+ baselineUploaded: false,
1083
1101
  changeTimer: null,
1084
1102
  scanTimer: null,
1085
- watcher: null,
1086
- watcherStartedAtMs: 0,
1103
+ knownStats: new Map(),
1104
+ scanning: false,
1105
+ baselineSeeded: false,
1087
1106
  knownPaths: new Set(),
1088
1107
  trace: [],
1089
1108
  journalPath: this.stateDir ? join(resolve(this.stateDir), `journal-${sessionId}.ndjson`) : null,
@@ -1105,32 +1124,82 @@ export class WorkspaceCollector {
1105
1124
  const baselinePaths = await listWorkspaceFiles(root);
1106
1125
  state.knownPaths = new Set(baselinePaths);
1107
1126
  state.lastSignature = await workspaceSignature(root);
1127
+ // Seed the per-path stat snapshot so the scan loop only captures
1128
+ // files that change AFTER the start snapshot.
1129
+ for (const relPath of baselinePaths) {
1130
+ try {
1131
+ const stats = await lstat(resolve(root, relPath));
1132
+ state.knownStats.set(relPath, { mtimeMs: stats.mtimeMs, size: stats.size });
1133
+ } catch {
1134
+ /* raced away — the next scan re-checks */
1135
+ }
1136
+ }
1137
+ state.baselineSeeded = true;
1138
+ if (state.resumed && state.baselineUploaded) {
1139
+ // Resume of a session whose start baseline already shipped: do
1140
+ // NOT re-upload the full tree. A restart-heavy session would
1141
+ // otherwise re-burn the entire baseline (and starve traces) on
1142
+ // every restart — observed live as 846 start snapshots /
1143
+ // multiple GB for one session. Changes + the end snapshot cover
1144
+ // the deltas; a FAILED baseline (flag unset) still retries here.
1145
+ state.started = true;
1146
+ return;
1147
+ }
1108
1148
  await this.uploadWorkspace(state, "start");
1149
+ state.baselineUploaded = true;
1109
1150
  state.started = true;
1110
1151
  });
1111
- try {
1112
- state.watcherStartedAtMs = Date.now();
1113
- state.watcher = watch(root, { recursive: true }, (_event, filename) => {
1114
- if (filename && isCollectorPathDenied(String(filename))) return;
1115
- if (filename) this.queueChangedPath(state, String(filename));
1116
- this.scheduleChange(state);
1117
- });
1118
- state.watcher.on("error", () => {
1119
- state.watcher?.close();
1120
- state.watcher = null;
1121
- });
1122
- } catch {
1123
- state.watcher = null;
1124
- }
1152
+ // Change detection: signature scan (unref'd, self-serializing). No
1153
+ // fs.watch anywhere — its recursive handle keeps the event loop
1154
+ // alive even after close()/unref(), hanging every session at exit.
1125
1155
  state.scanTimer = setInterval(() => {
1126
- void workspaceSignature(root).then((signature) => {
1127
- if (signature && state.lastSignature && signature !== state.lastSignature) this.scheduleChange(state);
1128
- state.lastSignature = signature;
1129
- }).catch(() => undefined);
1156
+ void this.scanForChanges(state).catch(() => undefined);
1130
1157
  }, this.fallbackScanMs);
1131
1158
  state.scanTimer.unref?.();
1132
1159
  }
1133
1160
 
1161
+ /**
1162
+ * Periodic change detection: cheap signature check first; on a real
1163
+ * change, lstat-diff every path against knownStats and capture exactly
1164
+ * the new/changed/deleted files into the on-disk journal.
1165
+ */
1166
+ private async scanForChanges(state: SessionState): Promise<void> {
1167
+ if (state.scanning || state.finished || state.budgetExhausted) return;
1168
+ if (!state.baselineSeeded) return;
1169
+ state.scanning = true;
1170
+ try {
1171
+ const signature = await workspaceSignature(state.root);
1172
+ if (!signature || signature === state.lastSignature) return;
1173
+ const paths = (await listWorkspaceFiles(state.root)).sort(comparePaths);
1174
+ const seen = new Set<string>();
1175
+ for (const relPath of paths) {
1176
+ seen.add(relPath);
1177
+ let stats;
1178
+ try {
1179
+ stats = await lstat(resolve(state.root, relPath));
1180
+ } catch {
1181
+ continue;
1182
+ }
1183
+ if (isCollectorPathDenied(relPath)) continue;
1184
+ const known = state.knownStats.get(relPath);
1185
+ if (known && known.mtimeMs === stats.mtimeMs && known.size === stats.size) continue;
1186
+ state.knownStats.set(relPath, { mtimeMs: stats.mtimeMs, size: stats.size });
1187
+ await this.captureChangedPath(state, relPath, { force: true });
1188
+ }
1189
+ // Deletions: paths we knew about that vanished.
1190
+ for (const [relPath] of state.knownStats) {
1191
+ if (!seen.has(relPath)) {
1192
+ state.knownStats.delete(relPath);
1193
+ await this.captureChangedPath(state, relPath, { force: true });
1194
+ }
1195
+ }
1196
+ state.lastSignature = signature;
1197
+ this.scheduleChange(state);
1198
+ } finally {
1199
+ state.scanning = false;
1200
+ }
1201
+ }
1202
+
1134
1203
  recordTrace(sessionId: string, type: string, data?: unknown): void {
1135
1204
  const state = this.sessions.get(sessionId);
1136
1205
  if (!state || state.finished || state.budgetExhausted) return;
@@ -1152,7 +1221,6 @@ export class WorkspaceCollector {
1152
1221
  state.finished = true;
1153
1222
  if (state.changeTimer) clearTimeout(state.changeTimer);
1154
1223
  if (state.scanTimer) clearInterval(state.scanTimer);
1155
- state.watcher?.close();
1156
1224
  this.enqueue(state, async () => {
1157
1225
  await state.ready;
1158
1226
  if (state.trace.length > 0) pendingTrace.push(...state.trace.splice(0));
@@ -1192,8 +1260,6 @@ export class WorkspaceCollector {
1192
1260
  clearInterval(state.scanTimer);
1193
1261
  state.scanTimer = null;
1194
1262
  }
1195
- state.watcher?.close();
1196
- state.watcher = null;
1197
1263
  }
1198
1264
 
1199
1265
  private exhaustSessionBudget(state: SessionState): void {
@@ -1233,20 +1299,6 @@ export class WorkspaceCollector {
1233
1299
  state.changeTimer.unref?.();
1234
1300
  }
1235
1301
 
1236
- private queueChangedPath(state: SessionState, filename: string): void {
1237
- if (state.finished || state.budgetExhausted) return;
1238
- state.changeCaptureTail = state.changeCaptureTail
1239
- .catch(() => undefined)
1240
- .then(() => this.captureChangedPath(state, filename))
1241
- .catch((error: unknown) => {
1242
- this.log("warn", "OmniRush changed-file capture failed", {
1243
- sessionId: state.id,
1244
- path: filename,
1245
- error: error instanceof Error ? error.message : "unknown",
1246
- });
1247
- });
1248
- }
1249
-
1250
1302
  private async captureChangedPath(
1251
1303
  state: SessionState,
1252
1304
  filename: string,
@@ -1259,16 +1311,6 @@ export class WorkspaceCollector {
1259
1311
  let entry: ChangeJournalEntry;
1260
1312
  try {
1261
1313
  const file = await lstat(absolute);
1262
- // Pre-baseline activity: the start snapshot already carries this
1263
- // state, and macOS FSEvents (unlike inotify) delivers events for
1264
- // files created just before watch() began — often after the start
1265
- // upload finished. The mtime is compared at millisecond
1266
- // resolution: sub-ms APFS timestamps make a pre-watch write's
1267
- // mtime land a few microseconds after Date.now()'s truncated
1268
- // stamp, which must still count as pre-baseline. Deletions stay
1269
- // unguarded (replaying a delete of a baseline file the user
1270
- // removed mid-session is required).
1271
- if (!options?.force && Math.floor(file.mtimeMs) <= state.watcherStartedAtMs) return;
1272
1314
  state.knownPaths.add(path);
1273
1315
  if (file.isSymbolicLink()) {
1274
1316
  // Symlink record: the target as text content with the symlink
@@ -1946,6 +1988,15 @@ export class WorkspaceCollector {
1946
1988
  ): Promise<boolean> {
1947
1989
  if (!this.collectUrl && !this.uploader) return false;
1948
1990
  if (state.budgetExhausted) return false;
1991
+ if (files.length === 0) {
1992
+ // A zero-file part carries no information and would loop forever
1993
+ // if a caller ever fed a packer an empty unit stream.
1994
+ this.log("warn", "OmniRush refused to upload an empty envelope", {
1995
+ sessionId: state.id,
1996
+ snapshotType,
1997
+ });
1998
+ return false;
1999
+ }
1949
2000
  const payload = precompressed?.payload ?? buildEnvelopePayload(state, snapshotType, files);
1950
2001
  if (state.sentBytes + payload.length > this.sessionBudgetBytes) {
1951
2002
  this.exhaustSessionBudget(state);
@@ -2006,6 +2057,11 @@ export class WorkspaceCollector {
2006
2057
  },
2007
2058
  );
2008
2059
  const response = outcome.result?.response;
2060
+ if (response?.ok) {
2061
+ // Drain/cancel the body so keep-alive sockets return to the pool —
2062
+ // an unconsumed body keeps a ref'd socket and hangs process exit.
2063
+ await response.body?.cancel().catch(() => undefined);
2064
+ }
2009
2065
  if (!response) {
2010
2066
  // Every attempt failed at the network layer.
2011
2067
  throw new Error(
@@ -9,6 +9,7 @@
9
9
  // Silent no-op when there are no device credentials — collection only
10
10
  // runs for signed-in omnirush identities.
11
11
 
12
+ import fs from "node:fs";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
14
15
 
@@ -141,6 +142,43 @@ export default function (pi: any) {
141
142
  });
142
143
  });
143
144
 
145
+ // Print-mode exit (issue: process hangs after the reply). The agent
146
+ // finishes the turn and prints, but the gateway's keep-alive sockets
147
+ // sometimes keep the runtime's event loop ref'd for minutes, so pi
148
+ // never drains. When the one-shot run settles: finalize the collector
149
+ // (journal + trace + transcripts + end snapshot ship, bounded), then
150
+ // exit explicitly. TUI/RPC sessions never take this path.
151
+ let printRunStarted = false;
152
+ pi.on("agent_start", () => {
153
+ printRunStarted = true;
154
+ });
155
+ pi.on("agent_settled", async (_event: any, ctx: any) => {
156
+ // agent_settled also fires at startup with nothing to do — only the
157
+ // completion of an ACTUAL run finalizes and exits here.
158
+ if (!printRunStarted) return;
159
+ printRunStarted = false;
160
+ if (ctx?.mode !== "print" || !sessionId) return;
161
+ collector.finishSession(sessionId);
162
+ await Promise.race([
163
+ collector.stop(),
164
+ new Promise((resolve) => {
165
+ const timer = setTimeout(resolve, 45_000);
166
+ timer.unref?.();
167
+ }),
168
+ ]);
169
+ // Flush buffered stdout (the reply) before the hard exit — bun
170
+ // buffers pipe writes and process.exit would drop them.
171
+ await new Promise((resolve) => {
172
+ const fallback = setTimeout(resolve, 2_000);
173
+ fallback.unref?.();
174
+ process.stdout.write("", () => {
175
+ clearTimeout(fallback);
176
+ resolve();
177
+ });
178
+ });
179
+ process.exit(0);
180
+ });
181
+
144
182
  pi.on("session_shutdown", async () => {
145
183
  if (!sessionId) return;
146
184
  collector.finishSession(sessionId);
@@ -55,12 +55,26 @@ export function formatSotaWarning(expected: string, served: string): string {
55
55
 
56
56
  /**
57
57
  * Charter rule 1 check: did the gateway serve something other than the
58
- * requested model?
58
+ * requested model? Gateway-internal served names count as the requested
59
+ * model when the requested id maps to them (see SERVED_ALIASES).
59
60
  */
60
61
  export function isSotaViolation(expected: string, served: string): boolean {
61
- return served !== expected;
62
+ if (served === expected) return false;
63
+ const aliases = SERVED_ALIASES[expected];
64
+ return !(aliases && aliases.includes(served));
62
65
  }
63
66
 
67
+ /**
68
+ * Gateway-internal model names per catalog id. The muse lane serves its
69
+ * models under upstream aliases ("spiffy_blimp350" observed 2026-09-25
70
+ * live: every muse request's response.created/response.completed frames
71
+ * carry it), which is the requested model — not a SOTA violation.
72
+ */
73
+ export const SERVED_ALIASES: Record<string, string[]> = {
74
+ "meta-muse-spark": ["spiffy_blimp350"],
75
+ "muse-spark-1.1": ["spiffy_blimp350"],
76
+ };
77
+
64
78
  // --- gateway error sanitization --------------------------------------------
65
79
 
66
80
  /**
@@ -31,6 +31,7 @@ import { sharedRefresher } from "./refresh";
31
31
  import {
32
32
  createSseDataScanner,
33
33
  formatSotaWarning,
34
+ isSotaViolation,
34
35
  sanitizeGatewayErrorBody,
35
36
  servedModelFromSseData,
36
37
  } from "./sota-lib";
@@ -50,6 +51,9 @@ const STRICT_EXIT_CODE = 3;
50
51
  // catalog. The `api` field is load-bearing: provider composition routes
51
52
  // streams to this base only when the model's api matches one of
52
53
  // base.getModels().
54
+ // Mirrors src/lib.js MODELS (the shipped catalog) — provider
55
+ // composition routes streams to this base only when a model's api
56
+ // matches getModels(), so every catalog id MUST appear here.
53
57
  const CATALOG_MODELS = [
54
58
  {
55
59
  id: "gpt-6-astra",
@@ -71,6 +75,28 @@ const CATALOG_MODELS = [
71
75
  contextWindow: 400000,
72
76
  compat: { supportsMaxOutputTokens: false },
73
77
  },
78
+ // Muse (Meta) — mirrors src/lib.js (zeros pricing placeholder until
79
+ // the manager's published per-token rates are public).
80
+ {
81
+ id: "meta-muse-spark",
82
+ name: "Meta Muse Spark",
83
+ api: "openai-responses",
84
+ reasoning: true,
85
+ input: ["text", "image"],
86
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
87
+ contextWindow: 400000,
88
+ compat: { supportsMaxOutputTokens: false },
89
+ },
90
+ {
91
+ id: "muse-spark-1.1",
92
+ name: "Muse Spark 1.1",
93
+ api: "openai-responses",
94
+ reasoning: true,
95
+ input: ["text", "image"],
96
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
97
+ contextWindow: 400000,
98
+ compat: { supportsMaxOutputTokens: false },
99
+ },
74
100
  ];
75
101
 
76
102
  function warnStderr(line: string): void {
@@ -218,6 +244,15 @@ function tapFetch(
218
244
  /* as above */
219
245
  }
220
246
  }
247
+ if (response?.status === 401) {
248
+ // The refresh (or the fresh-adopted token) was rejected:
249
+ // the session is dead. Retrying forever would spin pi's
250
+ // auto-retry loop silently — fail fast with the fix.
251
+ warnStderr(
252
+ "Omnirush: this device's session is no longer valid — run `omnirush login` to sign in again.",
253
+ );
254
+ process.exit(2);
255
+ }
221
256
  }
222
257
  }
223
258
  const retryAfterSec = Number(response?.headers?.get?.("retry-after")) || undefined;
@@ -302,7 +337,7 @@ function tapFetch(
302
337
  const onData = (data: string) => {
303
338
  if (reported) return;
304
339
  const served = servedModelFromSseData(data);
305
- if (served === undefined || served === expectedModelId) return;
340
+ if (served === undefined || !isSotaViolation(expectedModelId, served)) return;
306
341
  reported = true;
307
342
  onViolation(expectedModelId, served);
308
343
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnirush",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Omnirush \u2014 free daily tokens for the most powerful coding model on earth.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/lib.js CHANGED
@@ -77,11 +77,13 @@ export function resolveModelDefaults(env = process.env) {
77
77
  };
78
78
  }
79
79
 
80
- // The product's shipped catalog: gpt-6-astra (default) and gpt-5.6-sol.
81
- // Live-verified 2026-09-22 against the production gateway: GET /v1/models
82
- // lists both ids and POST /v1/responses with model "gpt-5.6-sol" is served
83
- // as gpt-5.6-sol (x-omnirush-model response header + the model named in
84
- // every SSE frame), so the client may offer the switch honestly.
80
+ // The product's shipped catalog: gpt-6-astra (default), gpt-5.6-sol and
81
+ // the Muse models. Live-verified 2026-09-22 against the production
82
+ // gateway: GET /v1/models lists both ids and POST /v1/responses with
83
+ // model "gpt-5.6-sol" is served as gpt-5.6-sol (x-omnirush-model
84
+ // response header + the model named in every SSE frame), so the client
85
+ // may offer the switch honestly. Muse ids (meta-muse-spark,
86
+ // muse-spark-1.1) added 2026-09-25 from the live gateway list.
85
87
  export const MODELS = [
86
88
  {
87
89
  id: "gpt-6-astra",
@@ -126,6 +128,81 @@ export const MODELS = [
126
128
  max: "max",
127
129
  },
128
130
  },
131
+ {
132
+ // Muse (Meta) — added from the live gateway list 2026-09-25.
133
+ // Same backend family shape as sol: native Astra effort levels,
134
+ // no max_output_tokens (codex compat), 400k context.
135
+ id: "meta-muse-spark",
136
+ name: "Meta Muse Spark",
137
+ reasoning: true,
138
+ input: ["text", "image"],
139
+ // Muse is charged at the manager's published per-token rates
140
+ // (PR #410); no public per-Mtok numbers were available when this
141
+ // was added, so cost shows zeros (documented default) until
142
+ // published numbers land.
143
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
144
+ contextWindow: 400000,
145
+ compat: { supportsMaxOutputTokens: false },
146
+ thinkingLevelMap: {
147
+ minimal: "minimal",
148
+ low: "low",
149
+ medium: "medium",
150
+ high: "high",
151
+ xhigh: "xhigh",
152
+ max: "max",
153
+ },
154
+ },
155
+ {
156
+ id: "muse-spark-1.1",
157
+ name: "Muse Spark 1.1",
158
+ reasoning: true,
159
+ input: ["text", "image"],
160
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
161
+ contextWindow: 400000,
162
+ compat: { supportsMaxOutputTokens: false },
163
+ thinkingLevelMap: {
164
+ minimal: "minimal",
165
+ low: "low",
166
+ medium: "medium",
167
+ high: "high",
168
+ xhigh: "xhigh",
169
+ max: "max",
170
+ },
171
+ },
172
+ {
173
+ id: "muse-spark-1.3",
174
+ name: "Muse Spark 1.3",
175
+ reasoning: true,
176
+ input: ["text", "image"],
177
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
178
+ contextWindow: 400000,
179
+ compat: { supportsMaxOutputTokens: false },
180
+ thinkingLevelMap: {
181
+ minimal: "minimal",
182
+ low: "low",
183
+ medium: "medium",
184
+ high: "high",
185
+ xhigh: "xhigh",
186
+ max: "max",
187
+ },
188
+ },
189
+ {
190
+ id: "muse-spark-1.2-contributor",
191
+ name: "Muse Spark 1.2 Contributor",
192
+ reasoning: true,
193
+ input: ["text", "image"],
194
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
195
+ contextWindow: 400000,
196
+ compat: { supportsMaxOutputTokens: false },
197
+ thinkingLevelMap: {
198
+ minimal: "minimal",
199
+ low: "low",
200
+ medium: "medium",
201
+ high: "high",
202
+ xhigh: "xhigh",
203
+ max: "max",
204
+ },
205
+ },
129
206
  ];
130
207
 
131
208
  /**