omnirush 0.9.1 → 0.10.0

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.
@@ -39,6 +39,7 @@ import {
39
39
  type SubagentFallback,
40
40
  } from "./subagents-lib";
41
41
  import { childAuthEnv } from "./auth";
42
+ import { sanitizeToolEnvironment } from "./secret-env";
42
43
  import { yoloActive } from "./yolo-lib";
43
44
 
44
45
  /** childAuthEnv, never throwing (a spawn must not fail on the auth file). */
@@ -444,6 +445,8 @@ const ROLE_RE = /^\s*\{\s*"type"\s*:\s*"message_end"\s*,\s*"message"\s*:\s*\{\s*
444
445
  export class ChildEventScanner {
445
446
  finalText = "";
446
447
  turns = 0;
448
+ /** Monotonic count of model/tool events that represent real progress. */
449
+ progressSerial = 0;
447
450
  readonly toolsRunning = new Set<string>();
448
451
  private line = "";
449
452
  /** head: deciding from the first characters; keep: buffering the whole line; skip: dropping it. */
@@ -486,6 +489,7 @@ export class ChildEventScanner {
486
489
  const head = this.line.slice(0, CHILD_EVENT_HEAD_CHARS);
487
490
  const type = EVENT_TYPE_RE.exec(head)?.[1] ?? null;
488
491
  this.lineType = type;
492
+ if (type === "message_update" || type === "message_start" || type === "turn_start") this.progressSerial += 1;
489
493
  if (type === "message_end") {
490
494
  const role = ROLE_RE.exec(head)?.[1] ?? null;
491
495
  this.lineRole = role;
@@ -514,6 +518,7 @@ export class ChildEventScanner {
514
518
  }
515
519
 
516
520
  private tool(type: string, id: string): void {
521
+ this.progressSerial += 1;
517
522
  if (type === "tool_execution_start") this.toolsRunning.add(id);
518
523
  else this.toolsRunning.delete(id);
519
524
  }
@@ -530,7 +535,7 @@ export class ChildEventScanner {
530
535
  if (!trimmed || !trimmed.includes('"type"')) return;
531
536
  // A short line: skip the types nothing is read from without parsing them.
532
537
  const type = mode === "head" ? EVENT_TYPE_RE.exec(trimmed)?.[1] : undefined;
533
- if (type && type !== "message_end" && type !== "tool_execution_start" && type !== "tool_execution_end") return;
538
+ if (type && type !== "message_end" && type !== "message_update" && type !== "message_start" && type !== "turn_start" && type !== "tool_execution_start" && type !== "tool_execution_end") return;
534
539
  let event: any;
535
540
  try {
536
541
  event = JSON.parse(trimmed);
@@ -541,10 +546,15 @@ export class ChildEventScanner {
541
546
  this.tool(event.type, event.toolCallId);
542
547
  return;
543
548
  }
549
+ if (event?.type === "message_update" || event?.type === "message_start" || event?.type === "turn_start") {
550
+ this.progressSerial += 1;
551
+ return;
552
+ }
544
553
  if (event?.type !== "message_end" || !event.message) return;
545
554
  const message = event.message;
546
555
  if (message.role !== "assistant") return;
547
556
  this.turns += 1;
557
+ this.progressSerial += 1;
548
558
  const parts = Array.isArray(message.content) ? message.content : [];
549
559
  const text = parts
550
560
  .filter((part: any) => part?.type === "text" && typeof part.text === "string")
@@ -602,7 +612,7 @@ export interface SpawnChildOptions {
602
612
  now?: () => number;
603
613
  /** Progress callback: partial output lines as the child runs. */
604
614
  onChildStdout?: (role: AgentRole, chunk: string) => void;
605
- /** Every sign of life from the child (any output), with its event types. */
615
+ /** Meaningful model/tool progress from the child, with its event types. */
606
616
  onActivity?: (activity: ChildActivity) => void;
607
617
  /** The child's session id (a fresh UUID by default). */
608
618
  sessionId?: string;
@@ -618,6 +628,7 @@ export interface ChildActivity {
618
628
  turns: number;
619
629
  /** Tool calls running right now. */
620
630
  toolsRunning: number;
631
+ meaningful: true;
621
632
  }
622
633
 
623
634
  /**
@@ -677,6 +688,7 @@ export async function runChildAgent(
677
688
  const child = spawnImpl(invocation.command, invocation.args, {
678
689
  cwd: options.cwd,
679
690
  shell: false,
691
+ detached: process.platform !== "win32",
680
692
  stdio: ["ignore", "pipe", "pipe"],
681
693
  // Shared credentials by location (OMNIRUSH_DIR), never a token
682
694
  // frozen at the parent's launch: the child re-reads auth.json for
@@ -697,16 +709,33 @@ export async function runChildAgent(
697
709
  };
698
710
 
699
711
  const killTree = () => {
700
- try {
701
- child.kill("SIGTERM");
702
- } catch {
703
- /* already gone */
712
+ const signalGroup = (signal: NodeJS.Signals) => {
713
+ if (process.platform !== "win32" && typeof child.pid === "number" && child.pid > 0) {
714
+ try {
715
+ process.kill(-child.pid, signal);
716
+ return true;
717
+ } catch {
718
+ /* fall back to the direct child below */
719
+ }
720
+ }
721
+ if (process.platform === "win32" && typeof child.pid === "number" && child.pid > 0) {
722
+ try {
723
+ const args = ["/PID", String(child.pid), "/T"];
724
+ if (signal === "SIGKILL") args.push("/F");
725
+ nodeSpawn("taskkill", args, { windowsHide: true, stdio: "ignore" });
726
+ return true;
727
+ } catch {
728
+ /* fall back to the direct child below */
729
+ }
730
+ }
731
+ return false;
732
+ };
733
+ if (!signalGroup("SIGTERM")) {
734
+ try { child.kill("SIGTERM"); } catch { /* already gone */ }
704
735
  }
705
736
  setTimeout(() => {
706
- try {
707
- child.kill("SIGKILL");
708
- } catch {
709
- /* already gone */
737
+ if (!signalGroup("SIGKILL")) {
738
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
710
739
  }
711
740
  }, Math.max(50, options.killGraceMs ?? CHILD_KILL_GRACE_MS)).unref?.();
712
741
  };
@@ -726,10 +755,11 @@ export async function runChildAgent(
726
755
  watchdog = setTimeout(() => kill("stalled"), window);
727
756
  watchdog.unref?.();
728
757
  };
729
- const alive = () => {
758
+ const alive = (meaningful: boolean) => {
759
+ if (!meaningful) return;
730
760
  lastActivity = now();
731
761
  armWatchdog();
732
- options.onActivity?.({ at: lastActivity, turns: events.turns, toolsRunning: toolsRunning.size });
762
+ options.onActivity?.({ at: lastActivity, turns: events.turns, toolsRunning: toolsRunning.size, meaningful: true });
733
763
  };
734
764
  armWatchdog();
735
765
 
@@ -749,8 +779,9 @@ export async function runChildAgent(
749
779
  child.stdout?.setEncoding?.("utf8");
750
780
  child.stdout?.on("data", (chunk: Buffer | string) => {
751
781
  const text = String(chunk);
782
+ const before = events.progressSerial;
752
783
  events.push(text);
753
- alive();
784
+ alive(events.progressSerial !== before);
754
785
  options.onChildStdout?.(task.role, text);
755
786
  });
756
787
  child.stderr?.setEncoding?.("utf8");
@@ -760,10 +791,17 @@ export async function runChildAgent(
760
791
  const lines = (pendingErrLine + text).split("\n");
761
792
  pendingErrLine = lines.pop() ?? "";
762
793
  if (pendingErrLine.length > 64 * 1024) pendingErrLine = pendingErrLine.slice(-64 * 1024);
763
- for (const line of lines) gatewayFallback = parseFallbackMarker(line) ?? gatewayFallback;
794
+ let meaningful = false;
795
+ for (const line of lines) {
796
+ const fallback = parseFallbackMarker(line);
797
+ if (fallback) {
798
+ gatewayFallback = fallback;
799
+ meaningful = true;
800
+ }
801
+ }
764
802
  stderr += text;
765
803
  if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
766
- alive();
804
+ alive(meaningful);
767
805
  });
768
806
  child.on("error", (error: Error) => {
769
807
  finish({
@@ -42,6 +42,7 @@ import {
42
42
  } from "./bgshell-lib";
43
43
  import { deliveryHub } from "./deliveries";
44
44
  import { BACKGROUND_BASH_TYPE } from "./pi-engine";
45
+ import { sanitizeToolEnvironment } from "./secret-env";
45
46
 
46
47
  /** customType of the message that brings finished background commands back (the trace maps it to bash tool parts). */
47
48
  export const BASH_DELIVERY_TYPE = BACKGROUND_BASH_TYPE;
@@ -221,7 +222,14 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
221
222
  const operations = {
222
223
  exec: (command: string, cwd: string, execOptions: any) =>
223
224
  new Promise<{ exitCode: number | null }>((resolve, reject) => {
224
- const current = manager.start({ session, command, cwd, env: execOptions?.env, toolCallId, onData: execOptions?.onData });
225
+ const current = manager.start({
226
+ session,
227
+ command,
228
+ cwd,
229
+ env: sanitizeToolEnvironment(execOptions?.env ?? process.env),
230
+ toolCallId,
231
+ onData: execOptions?.onData,
232
+ });
225
233
  job = current;
226
234
  let timer: ReturnType<typeof setTimeout> | null = null;
227
235
  const onAbort = () => { void manager.kill([current]); };
@@ -79,10 +79,6 @@ const MAX_JOB_AGE_MS = 7 * 24 * 60 * 60_000;
79
79
  const JOB_RETRY_BASE_MS = 60_000;
80
80
  const JOB_RETRY_MAX_MS = 60 * 60_000;
81
81
  const SIGN_OUT_ABORT_TIMEOUT_MS = 5_000;
82
- /** At app start, sessions active (a base, a prompt, a turn) within this long may get a final archive (startFinalCandidates). */
83
- const START_FINAL_WINDOW_MS = 7 * 24 * 60 * 60_000;
84
- /** At most this many sessions are scanned for a final archive at app start. */
85
- const MAX_START_FINALS = 10;
86
82
  /**
87
83
  * How long a folder policy answer (4.4: all folders, touched files) is
88
84
  * reused for folders without `.git`: sessions started meanwhile send no
@@ -515,12 +511,9 @@ export class SessionArchiver {
515
511
  * with a turn captured since its last final archive (the app quit before
516
512
  * that final, or crashed), the most recently active session on each
517
513
  * other folder (the folder may have changed while the app was closed),
518
- * and every touched-files session (its files are its own). Only sessions
519
- * active (a base, a prompt, a turn) within the last 7 days, not stopped
520
- * and not deleted, with a base or (touched files) a touched path: a final
521
- * archive never extends the window, so a chat left alone gets none a week
522
- * after its last turn, however often the app starts. The most recently
523
- * active first, at most 10.
514
+ * and every touched-files session (its files are its own). Sessions are
515
+ * recovered until their final archive is settled; there is no age or count
516
+ * cutoff that can silently lose the final workspace state.
524
517
  */
525
518
  async startFinalCandidates(): Promise<string[]> {
526
519
  try {
@@ -533,7 +526,7 @@ export class SessionArchiver {
533
526
  const parsed = sessionStateSchema.safeParse(await readJsonFile(join(this.dirs.sessions, name)));
534
527
  if (!parsed.success || name !== `${stateKey(parsed.data.session_id)}.json`) continue;
535
528
  const state = parsed.data;
536
- if (state.stopped || state.ended || !(nowMs - Date.parse(state.last_activity_at) <= START_FINAL_WINDOW_MS)) continue;
529
+ if (state.stopped || state.ended || Date.parse(state.last_activity_at) > nowMs) continue;
537
530
  // Without a base, only a touched-files session that touched something may get one.
538
531
  if (state.next_sequence === 0 && !(state.marker === TOUCHED_MARKER && (await this.touched.has(state.session_id)))) continue;
539
532
  sessions.push(state);
@@ -548,7 +541,7 @@ export class SessionArchiver {
548
541
  roots.add(folder);
549
542
  if (state.final_due || newestOnRoot) picked.push(state.session_id);
550
543
  }
551
- return picked.slice(0, MAX_START_FINALS);
544
+ return picked;
552
545
  } catch (error) {
553
546
  this.log("warn", "OmniRush archive could not list the sessions to check at start", { error: errorSummary(error) });
554
547
  return [];
@@ -39,8 +39,8 @@ import {
39
39
  import { stateKey } from "./files.js";
40
40
  import type { ArchiveLog } from "./upload.js";
41
41
 
42
- /** A session keeps at most this many touched paths; later ones are not archived (one log line). */
43
- export const MAX_TOUCHED_PATHS = 100_000;
42
+ /** Kept as a compatibility export; touched paths are no longer silently capped. */
43
+ export const MAX_TOUCHED_PATHS = Number.MAX_SAFE_INTEGER;
44
44
  const MAX_TOUCHED_PATH_CHARS = 4_096;
45
45
  /** A touched symlink is followed to a file inside the root through at most this many links. */
46
46
  const MAX_LINK_HOPS = 8;
@@ -387,11 +387,6 @@ export class TouchedPathStore {
387
387
  }, () => undefined);
388
388
  }
389
389
  if (session.mode === "ignored" || session.known?.has(path) || session.pending.has(path) || touchedPathParts(path) === null) return;
390
- if ((session.known?.size ?? 0) + session.pending.size + session.writing.length >= MAX_TOUCHED_PATHS) {
391
- if (!session.capped) this.options.log("warn", "OmniRush touched-files archive keeps a limited number of paths per session; later ones are not archived", { sessionId, limit: MAX_TOUCHED_PATHS });
392
- session.capped = true;
393
- return;
394
- }
395
390
  session.pending.add(path);
396
391
  if (session.mode === "tracked") this.schedule();
397
392
  }
@@ -92,7 +92,6 @@ const MAX_SESSION_LEDGER_ENTRIES = 512;
92
92
  const SESSION_LEDGER_LOCK_TIMEOUT_MS = 5_000;
93
93
  const SESSION_LEDGER_LOCK_STALE_MS = 60_000;
94
94
  const SESSION_LEDGER_LOCK_RETRY_MS = 25;
95
- const MAX_TOUCHED_PATHS = 128;
96
95
  const MAX_GIT_STATUS_ENTRIES = 500;
97
96
  const MAX_GIT_RECENT_COMMITS = 50;
98
97
  const MAX_GIT_REMOTES = 10;
@@ -4435,7 +4434,7 @@ export class WorkspaceCollector {
4435
4434
  }
4436
4435
 
4437
4436
  private touchedPathsForUpload(state: SessionState): string[] {
4438
- return [...state.touchedPaths].slice(0, MAX_TOUCHED_PATHS).map(collectorPathForUpload);
4437
+ return [...state.touchedPaths].map(collectorPathForUpload);
4439
4438
  }
4440
4439
 
4441
4440
  /**
@@ -43,6 +43,7 @@ import {
43
43
  type McpServerConfig,
44
44
  } from "./mcp-lib";
45
45
  import { truncateTail } from "@earendil-works/pi-coding-agent";
46
+ import { sanitizeToolEnvironment } from "./secret-env";
46
47
 
47
48
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
48
49
 
@@ -218,7 +219,7 @@ async function connectServer(state: ServerState): Promise<void> {
218
219
  transport = new StdioClientTransport({
219
220
  command: state.config.command!,
220
221
  args: state.config.args ?? [],
221
- ...(state.config.env ? { env: state.config.env } : {}),
222
+ env: sanitizeToolEnvironment({ ...process.env, ...(state.config.env ?? {}) }),
222
223
  stderr: "pipe",
223
224
  });
224
225
  }
@@ -0,0 +1,15 @@
1
+ /** Environment passed to user-controlled tools and MCP servers. */
2
+ const SECRET_ENV_NAMES = new Set([
3
+ "OMNIRUSH_TOKEN",
4
+ "OMNIRUSH_ACCESS_TOKEN",
5
+ "OMNIRUSH_APPROVAL_TOKEN",
6
+ ]);
7
+
8
+ /** Keep provider credentials in the agent process, never in shell/MCP children. */
9
+ export function sanitizeToolEnvironment(input: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
10
+ const output: NodeJS.ProcessEnv = {};
11
+ for (const [key, value] of Object.entries(input)) {
12
+ if (!SECRET_ENV_NAMES.has(key) && value !== undefined) output[key] = value;
13
+ }
14
+ return output;
15
+ }
@@ -131,6 +131,11 @@ export function catalogFromPayload(payload: unknown): CatalogModel[] | null {
131
131
  if (!isRecord(entry) || !isModelId(entry.id)) continue;
132
132
  const id = entry.id.trim();
133
133
  if (out.some((model) => model.id === id)) continue;
134
+ const ownership = [entry.provider, entry.owned_by, entry.owner, entry.source, entry.namespace, entry.managed_by]
135
+ .filter((value): value is string => typeof value === "string")
136
+ .map((value) => value.trim().toLowerCase());
137
+ const shipped = ["gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol", "meta-muse-spark", "muse-spark-1.1", "muse-spark-1.3", "muse-spark-1.2-contributor"];
138
+ if (!shipped.includes(id) && entry.omnirush !== true && !ownership.includes("omnirush")) continue;
134
139
  const levels = Array.isArray(entry.reasoning_levels)
135
140
  ? EFFORTS.filter((effort) => entry.reasoning_levels.some((level: unknown) => parseEffort(level) === effort))
136
141
  : [...staticLevels(id)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnirush",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
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",
@@ -45,4 +45,4 @@
45
45
  "optionalDependencies": {
46
46
  "@picovoice/pvrecorder-node": "1.2.9"
47
47
  }
48
- }
48
+ }
package/src/bin.js CHANGED
@@ -160,7 +160,71 @@ function ensureDirs() {
160
160
  } catch {
161
161
  /* best effort — pi falls back to PATH, then its own downloader */
162
162
  }
163
- installDefaultThinkingLevel();
163
+ withInstallLock(installDefaultThinkingLevel);
164
+ }
165
+
166
+ const INSTALL_LOCK = path.join(OMNI_DIR, ".install.lock");
167
+ const INSTALL_LOCK_STALE_MS = 60_000;
168
+ const WINDOWS_INSTALL_BUSY = new Set(["EPERM", "EACCES", "EBUSY"]);
169
+
170
+ function sleepSync(milliseconds) {
171
+ const wait = new Int32Array(new SharedArrayBuffer(4));
172
+ Atomics.wait(wait, 0, 0, milliseconds);
173
+ }
174
+
175
+ /** Serialize launcher state changes across concurrent CLI processes. */
176
+ function withInstallLock(task) {
177
+ fs.mkdirSync(OMNI_DIR, { recursive: true, mode: 0o700 });
178
+ const started = Date.now();
179
+ for (;;) {
180
+ try {
181
+ fs.mkdirSync(INSTALL_LOCK);
182
+ fs.writeFileSync(path.join(INSTALL_LOCK, "owner"), `${process.pid}\n`, { mode: 0o600 });
183
+ break;
184
+ } catch (error) {
185
+ if (error?.code !== "EEXIST") throw error;
186
+ try {
187
+ if (Date.now() - fs.statSync(INSTALL_LOCK).mtimeMs > INSTALL_LOCK_STALE_MS) {
188
+ fs.rmSync(INSTALL_LOCK, { recursive: true, force: true });
189
+ continue;
190
+ }
191
+ } catch {
192
+ continue;
193
+ }
194
+ if (Date.now() - started > 15_000) throw new Error("timed out waiting for the Omnirush installer lock");
195
+ sleepSync(25);
196
+ }
197
+ }
198
+ try {
199
+ return task();
200
+ } finally {
201
+ fs.rmSync(INSTALL_LOCK, { recursive: true, force: true });
202
+ }
203
+ }
204
+
205
+ /** Replace a state file only after its complete contents are ready. */
206
+ function writeFileAtomic(filename, contents, options = {}) {
207
+ const temporary = `${filename}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
208
+ const fd = fs.openSync(temporary, "wx", options.mode ?? 0o600);
209
+ try {
210
+ fs.writeSync(fd, contents);
211
+ try { fs.fsyncSync(fd); } catch { /* best effort on filesystems without fsync */ }
212
+ } finally {
213
+ fs.closeSync(fd);
214
+ }
215
+ try {
216
+ for (let attempt = 0; ; attempt++) {
217
+ try {
218
+ fs.renameSync(temporary, filename);
219
+ break;
220
+ } catch (error) {
221
+ if (attempt >= 9 || !WINDOWS_INSTALL_BUSY.has(error?.code)) throw error;
222
+ sleepSync(20 * (attempt + 1));
223
+ }
224
+ }
225
+ } finally {
226
+ fs.rmSync(temporary, { force: true });
227
+ }
164
228
  }
165
229
 
166
230
  /**
@@ -198,7 +262,7 @@ function installDefaultThinkingLevel() {
198
262
  return;
199
263
  }
200
264
  if (next !== settings) {
201
- fs.writeFileSync(PI_SETTINGS_JSON, JSON.stringify(next, null, 2) + "\n");
265
+ writeFileAtomic(PI_SETTINGS_JSON, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
202
266
  }
203
267
  }
204
268
 
@@ -237,7 +301,7 @@ async function launchModels(gatewayUrl, accessToken) {
237
301
  const models = catalogFromGateway(payload);
238
302
  if (models) {
239
303
  try {
240
- fs.writeFileSync(cacheFile, JSON.stringify({ gateway: base, fetched_at: new Date().toISOString(), payload }), { mode: 0o600 });
304
+ withInstallLock(() => writeFileAtomic(cacheFile, JSON.stringify({ gateway: base, fetched_at: new Date().toISOString(), payload }), { mode: 0o600 }));
241
305
  } catch {
242
306
  /* the cache is a convenience */
243
307
  }
@@ -263,18 +327,20 @@ async function launchModels(gatewayUrl, accessToken) {
263
327
  }
264
328
 
265
329
  function installModelsJson(gatewayUrl, models) {
266
- let current = {};
267
- try {
268
- current = JSON.parse(fs.readFileSync(PI_MODELS_JSON, "utf8"));
269
- } catch {
270
- /* fresh install */
271
- }
272
- if (typeof current !== "object" || current === null) current = {};
273
- current.providers = {
274
- ...(current.providers || {}),
275
- ...modelsConfig({ gatewayUrl, ...(models ? { models } : {}) }).providers,
276
- };
277
- fs.writeFileSync(PI_MODELS_JSON, JSON.stringify(current, null, 2) + "\n");
330
+ withInstallLock(() => {
331
+ let current = {};
332
+ try {
333
+ current = JSON.parse(fs.readFileSync(PI_MODELS_JSON, "utf8"));
334
+ } catch {
335
+ /* fresh install */
336
+ }
337
+ if (typeof current !== "object" || current === null) current = {};
338
+ current.providers = {
339
+ ...(current.providers || {}),
340
+ ...modelsConfig({ gatewayUrl, ...(models ? { models } : {}) }).providers,
341
+ };
342
+ writeFileAtomic(PI_MODELS_JSON, JSON.stringify(current, null, 2) + "\n", { mode: 0o600 });
343
+ });
278
344
  }
279
345
 
280
346
  function installExtensions() {
@@ -292,11 +358,25 @@ function installExtensions() {
292
358
  }
293
359
  }
294
360
  };
295
- copy(src, PI_EXT_DIR);
296
- // Modules of the earlier collector: nothing imports them any more.
297
- for (const stale of ["collector-lib.ts", "trace-format.ts"]) {
298
- fs.rmSync(path.join(PI_EXT_DIR, stale), { force: true });
299
- }
361
+ withInstallLock(() => {
362
+ const staging = path.join(path.dirname(PI_EXT_DIR), `.omnirush-extension-${process.pid}-${Date.now()}`);
363
+ const previous = `${PI_EXT_DIR}.previous-${process.pid}`;
364
+ fs.rmSync(staging, { recursive: true, force: true });
365
+ fs.rmSync(previous, { recursive: true, force: true });
366
+ copy(src, staging);
367
+ try {
368
+ if (fs.existsSync(PI_EXT_DIR)) fs.renameSync(PI_EXT_DIR, previous);
369
+ fs.renameSync(staging, PI_EXT_DIR);
370
+ fs.rmSync(previous, { recursive: true, force: true });
371
+ } catch {
372
+ // Windows can refuse a directory rename while another agent has a
373
+ // module open. Keep the running installation usable as a fallback.
374
+ fs.rmSync(staging, { recursive: true, force: true });
375
+ copy(src, PI_EXT_DIR);
376
+ } finally {
377
+ fs.rmSync(previous, { recursive: true, force: true });
378
+ }
379
+ });
300
380
  }
301
381
 
302
382
  function agentGatewayUrl(auth) {
@@ -561,7 +641,7 @@ async function cmdDoctor() {
561
641
  const gateway = agentGatewayUrl(loadAuth(OMNI_DIR));
562
642
  try {
563
643
  const res = await fetchJson(`${gateway}/models`, { timeout: 5000 });
564
- ok("manager", res.status === 401 || res.status < 500, `${origin} -> HTTP ${res.status}`);
644
+ ok("manager", res.status === 401 || res.status === 200, `${origin} -> HTTP ${res.status}`);
565
645
  } catch (e) {
566
646
  ok("manager", false, `${origin} unreachable (${e.message})`);
567
647
  }
@@ -607,7 +687,7 @@ async function cmdDoctor() {
607
687
  const r = await fetchJson("https://registry.npmjs.org/omnirush/latest", { timeout: 5000 });
608
688
  if (r.status === 200) {
609
689
  latest = JSON.parse(r.body).version;
610
- fs.writeFileSync(cacheFile, JSON.stringify({ latest, checked_at: Date.now() }));
690
+ withInstallLock(() => writeFileAtomic(cacheFile, JSON.stringify({ latest, checked_at: Date.now() }), { mode: 0o600 }));
611
691
  }
612
692
  }
613
693
  if (latest && latest !== own) {
package/src/lib.js CHANGED
@@ -234,8 +234,9 @@ const GATEWAY_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"];
234
234
  * The model list from the gateway's catalog (GET /v1/models, the list the
235
235
  * desktop app shows), in its order, for pi's models.json: a model the CLI
236
236
  * ships keeps its entry (pricing, effort map) under the gateway's display
237
- * name; a model it does not know gets a generic entry from the catalog's
238
- * fields (context limit, image input, reasoning). Shipped models the
237
+ * name; a future model is accepted only when the gateway explicitly marks
238
+ * it as Omnirush-owned. Arbitrary provider/BYOK entries are rejected.
239
+ * Shipped models the
239
240
  * gateway does not list (sub-agent-only Muse ids) follow, so
240
241
  * `spawn_agents` can still name them. Null when the payload is not a
241
242
  * usable catalog: the caller keeps the shipped list.
@@ -254,6 +255,10 @@ export function catalogFromGateway(payload, shipped = MODELS) {
254
255
  listed.push({ ...base, ...(name ? { name } : {}) });
255
256
  continue;
256
257
  }
258
+ const ownership = [entry.provider, entry.owned_by, entry.owner, entry.source, entry.namespace, entry.managed_by]
259
+ .filter((value) => typeof value === "string")
260
+ .map((value) => value.trim().toLowerCase());
261
+ if (entry.omnirush !== true && !ownership.includes("omnirush")) continue;
257
262
  const caps = entry.capabilities && typeof entry.capabilities === "object" ? entry.capabilities : {};
258
263
  const context = entry.limits && Number.isSafeInteger(entry.limits.context) && entry.limits.context > 0 ? entry.limits.context : 400000;
259
264
  const reasoning = caps.reasoning !== false;