omnirush 0.10.2 → 0.10.4

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.
@@ -35,6 +35,7 @@ import {
35
35
  ENV_FALLBACK_MODEL,
36
36
  FALLBACK_MARKER,
37
37
  fallbackNote,
38
+ sourceNote,
38
39
  piThinking,
39
40
  type SubagentFallback,
40
41
  } from "./subagents-lib";
@@ -198,11 +199,18 @@ export const ROLE_PRESETS: Record<AgentRole, RolePreset> = {
198
199
  },
199
200
  };
200
201
 
201
- /** Resolve the child entry: this running script + the current runtime. */
202
- export function childInvocation(args: string[]): {
202
+ /**
203
+ * Resolve the child entry: the core's entry as the launcher started it
204
+ * (OMNIRUSH_CORE_ENTRY: through <package>/engine, so process listings name
205
+ * omnirush; the runtime reports the resolved path in argv[1]), else this
206
+ * running script, with the current runtime.
207
+ */
208
+ export function childInvocation(args: string[], env: NodeJS.ProcessEnv = process.env): {
203
209
  command: string;
204
210
  args: string[];
205
211
  } {
212
+ const launched = (env.OMNIRUSH_CORE_ENTRY || "").trim();
213
+ if (launched && existsSync(launched)) return { command: process.execPath, args: [launched, ...args] };
206
214
  const currentScript = process.argv[1];
207
215
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
208
216
  if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
@@ -213,8 +221,8 @@ export function childInvocation(args: string[]): {
213
221
  if (!isGenericRuntime) {
214
222
  return { command: process.execPath, args };
215
223
  }
216
- // Packaged bun builds: fall back to the pi on PATH.
217
- return { command: "pi", args };
224
+ // Packaged bun builds: fall back to the omnirush on PATH (it forwards core flags).
225
+ return { command: "omnirush", args };
218
226
  }
219
227
 
220
228
  /** The folder, inside a session dir, that holds its sub-agents' sessions. */
@@ -305,8 +313,8 @@ export interface ChildTask {
305
313
  role: AgentRole;
306
314
  task: string;
307
315
  /** Cross-model children: gateway model id the child runs on
308
- * (e.g. "muse-spark-1.3" for cheap swarm workers under an astra
309
- * parent). Undefined = inherit the parent's model. */
316
+ * (e.g. "muse-spark-1.1" workers under an astra parent). Undefined =
317
+ * inherit the parent's model. */
310
318
  model?: string;
311
319
  /** The effort the child runs on (gateway spelling); undefined = pi's default. */
312
320
  effort?: string;
@@ -316,6 +324,21 @@ export interface ChildTask {
316
324
  gatewayFallback?: { model: string; effort: string | null };
317
325
  /** Extra environment for the child (the sub-agent setting and main model, for nested layers). */
318
326
  env?: Record<string, string>;
327
+ /** The model[:effort] the delegating agent asked for (the task's own `model`), when it named one. */
328
+ requestedModel?: string;
329
+ /** That model did not run: the user's /subagents choice won, or the gateway does not serve it. */
330
+ override?: ChildModelOverride;
331
+ /** Where `model` comes from ("subagents", "user_prompt", "task", "parent"). */
332
+ modelSource?: string;
333
+ }
334
+
335
+ /** A task model that did not run, and what ran instead (the result's model_override). */
336
+ export interface ChildModelOverride {
337
+ requested: string;
338
+ used: string;
339
+ effort: string | null;
340
+ reason: string;
341
+ note: string;
319
342
  }
320
343
 
321
344
  /** A sub-agent that ran on the main model instead of the picked one. */
@@ -342,6 +365,12 @@ export interface ChildResult {
342
365
  effort?: string;
343
366
  /** It ran on the main model instead of the picked one (why, and since when). */
344
367
  model_fallback?: ChildModelFallback;
368
+ /** The task's own model did not run (the user's /subagents choice, or not served). */
369
+ model_override?: ChildModelOverride;
370
+ /** Where the model comes from: "subagents", "user_prompt", "task" or "parent". */
371
+ model_source?: string;
372
+ /** The model[:effort] the task asked for, when it named one. */
373
+ requested_model?: string;
345
374
  status: ChildStatus;
346
375
  /** Process exit code (null when killed by a signal or still unknown). */
347
376
  exitCode: number | null;
@@ -412,6 +441,9 @@ export function buildChildResult(
412
441
  ...(task.model ? { model: task.model } : {}),
413
442
  ...(task.effort ? { effort: task.effort } : {}),
414
443
  ...(modelFallback ? { model_fallback: modelFallback } : {}),
444
+ ...(task.override ? { model_override: { ...task.override } } : {}),
445
+ ...(task.modelSource ? { model_source: task.modelSource } : {}),
446
+ ...(task.requestedModel ? { requested_model: task.requestedModel } : {}),
415
447
  status: input.status ?? (input.exitCode === 0 ? "completed" : "failed"),
416
448
  exitCode: input.exitCode,
417
449
  output: capped,
@@ -921,16 +953,34 @@ export async function mapWithConcurrency<TIn, TOut>(
921
953
  return results;
922
954
  }
923
955
 
956
+ /** The model and effort a child really ran on (after a gateway fallback), and why that one. */
957
+ export function ranOn(result: Pick<ChildResult, "model" | "effort" | "model_fallback">): { model: string | null; effort: string | null } {
958
+ if (result.model_fallback?.kind === "gateway") return { model: result.model_fallback.used, effort: result.model_fallback.effort ?? null };
959
+ return { model: result.model ?? null, effort: result.effort ?? null };
960
+ }
961
+
962
+ function ranOnText(result: ChildResult): string {
963
+ const { model, effort } = ranOn(result);
964
+ if (!model) return "the parent's model (not an omnirush.ai model)";
965
+ const why = result.model_fallback?.kind === "gateway"
966
+ ? "the gateway refused the chosen model"
967
+ : result.model_fallback
968
+ ? "the main model"
969
+ : sourceNote(result.model_source);
970
+ return `${model}${effort ? ` · ${effort}` : ""}${why ? ` (${why})` : ""}`;
971
+ }
972
+
924
973
  /** Structured result text for the parent model (one section per child). */
925
974
  export function renderChildResults(results: ChildResult[], ids?: readonly string[]): string {
926
975
  const succeeded = results.filter((result) => result.status === "completed").length;
927
976
  const sections = results.map((result, index) => {
928
977
  const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
929
- const ranOn = result.model_fallback?.kind === "gateway" ? result.model_fallback.used : result.model;
930
- const effort = result.model_fallback?.kind === "gateway" ? result.model_fallback.effort ?? undefined : result.effort;
931
- const label = ranOn ? ` [${ranOn}${effort ? ` · ${effort}` : ""}]` : "";
978
+ const ran = ranOn(result);
979
+ const label = ran.model ? ` [${ran.model}${ran.effort ? ` · ${ran.effort}` : ""}]` : "";
932
980
  const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${label} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
933
981
  const meta: string[] = [`task: ${result.task}`];
982
+ meta.push(`ran on: ${ranOnText(result)}`);
983
+ if (result.model_override) meta.push(`note: ${result.model_override.note}`);
934
984
  if (result.model_fallback) meta.push(`note: ${result.model_fallback.note}`);
935
985
  if (result.error) meta.push(`error: ${result.error}`);
936
986
  return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
@@ -73,6 +73,7 @@ import {
73
73
  } from "./agents-lib";
74
74
  import { SWARM_GUIDELINE } from "./swarm-lib";
75
75
  import { omniDir } from "./auth";
76
+ import { loadCatalog, type CatalogModel } from "./subagents-lib";
76
77
  import { MemoryAdmission, readMemorySettings } from "./memory-lib";
77
78
 
78
79
  /** Blocking spawn_agents: progress updates at most this often (ms). */
@@ -84,16 +85,32 @@ const MIN_TIMEOUT_MINUTES = 1;
84
85
  /** customType of the message that brings background results back. */
85
86
  export const DELIVERY_TYPE = "omnirush-agents-result";
86
87
 
87
- const SpawnAgentsParams = Type.Object({
88
+ /** Served ids used as examples when the account's catalog is not cached (the live list at 0.10.3). */
89
+ export const SERVED_MODEL_EXAMPLES = ["gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol", "meta-muse-spark", "muse-spark-1.1"];
90
+
91
+ /** Examples for the per-task model: the ids the account's live catalog lists (served models only). */
92
+ export function modelExamples(catalog: readonly CatalogModel[] | null): string[] {
93
+ if (!catalog || catalog.length === 0) return SERVED_MODEL_EXAMPLES;
94
+ return catalog.map((model) => model.id);
95
+ }
96
+
97
+ export function modelParamDescription(examples: readonly string[]): string {
98
+ return [
99
+ "Optional model[:effort] for THIS task. Usually omit it: set it only when the user asked for a model for the sub-agents.",
100
+ `Served models: ${examples.map((id) => `"${id}"`).join(", ")}, optionally with an effort (e.g. "${examples[0] ?? "gpt-6-astra"}:high"); a model the account is not served is never used (the child runs on your model and effort).`,
101
+ "The user's /subagents choice always wins: while it is set, a model named here runs only if the user's own message names it.",
102
+ "Omitted, the child runs on the /subagents choice, else this session's current model and effort.",
103
+ ].join(" ");
104
+ }
105
+
106
+ const spawnAgentsParams = (examples: readonly string[]) => Type.Object({
88
107
  tasks: Type.Array(
89
108
  Type.Object({
90
109
  role: StringEnum(AGENT_ROLES, {
91
110
  description: "code-searcher: read-only codebase exploration; researcher-web: web research via web_search/web_fetch; general-worker: full-tool implementation work",
92
111
  }),
93
112
  task: Type.String({ description: "Self-contained task description for the child agent (it sees ONLY this, not your conversation)" }),
94
- model: Type.Optional(Type.String({
95
- description: 'Optional model[:effort] for THIS task (e.g. "muse-spark-1.3", "muse-spark-1.2-contributor" for cheap swarm workers, "meta-muse-spark", "muse-spark-1.1:low", "gpt-6-astra", "gpt-6-sol:high", "gpt-5.6-sol"). Omit (or leave empty) for the sub-agent model the user picked with /subagents, else the parent session\'s current model and effort',
96
- })),
113
+ model: Type.Optional(Type.String({ description: modelParamDescription(examples) })),
97
114
  }),
98
115
  { description: "Tasks to delegate; they all run in parallel", minItems: 1 },
99
116
  ),
@@ -115,6 +132,26 @@ const IdsParam = Type.Optional(Type.Array(Type.String(), {
115
132
  description: 'Agent ids from spawn_agents (e.g. "a3"); omit (or "all") for every agent of this session',
116
133
  }));
117
134
 
135
+ /** The text of the latest user message on the session's branch (null when there is none). */
136
+ export function lastUserMessageText(ctx: any): string | null {
137
+ let entries: any[] = [];
138
+ try {
139
+ entries = ctx?.sessionManager?.getBranch?.() ?? ctx?.sessionManager?.getEntries?.() ?? [];
140
+ } catch {
141
+ return null;
142
+ }
143
+ for (let index = entries.length - 1; index >= 0; index--) {
144
+ const message = entries[index]?.type === "message" ? entries[index].message : null;
145
+ if (!message || message.role !== "user") continue;
146
+ if (typeof message.content === "string") return message.content;
147
+ if (Array.isArray(message.content)) {
148
+ return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
149
+ }
150
+ return null;
151
+ }
152
+ return null;
153
+ }
154
+
118
155
  function resultOf(record: AgentRecord): ChildResult & { agent_id: string } {
119
156
  return { ...(record.result as ChildResult), agent_id: record.id };
120
157
  }
@@ -227,6 +264,30 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
227
264
  };
228
265
  (globalThis as any)[AGENTS_HOOK] = hook;
229
266
 
267
+ // The user's latest message (the text they typed, not an extension's):
268
+ // a task's own model runs over the /subagents choice only when it names
269
+ // that model. Sub-agents have no user: their task text never counts.
270
+ let lastUserInput: { session: string; text: string } | null = null;
271
+ const sessionIdOf = (ctx: any): string => {
272
+ try {
273
+ return String(ctx?.sessionManager?.getSessionId?.() ?? "");
274
+ } catch {
275
+ return "";
276
+ }
277
+ };
278
+ if (depth === 0) {
279
+ pi.on("input", (event: any, ctx: any) => {
280
+ if (event?.source === "extension" || typeof event?.text !== "string") return;
281
+ lastUserInput = { session: sessionIdOf(ctx), text: event.text };
282
+ });
283
+ }
284
+ const latestUserPrompt = (ctx: any): string | null => {
285
+ if (depth > 0) return null;
286
+ const session = sessionIdOf(ctx);
287
+ if (lastUserInput && lastUserInput.session === session) return lastUserInput.text;
288
+ return lastUserMessageText(ctx);
289
+ };
290
+
230
291
  const sessionOf = (ctx: any): string => {
231
292
  lastContext = ctx ?? lastContext;
232
293
  return hub.sessionOf(ctx);
@@ -244,7 +305,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
244
305
  "Default (wait true) blocks until all children finish and returns their results.",
245
306
  "wait:false dispatches them in the BACKGROUND: the call returns at once with agent ids, you keep working, and their results arrive automatically as a message in this conversation when they finish.",
246
307
  "Manage background agents with agents_status, agents_wait, agents_result and agents_cancel.",
247
- 'Each task can run on a DIFFERENT model via its "model" field — e.g. cheap muse workers for wide swarm sweeps under an astra parent.',
308
+ 'Each task can run on a different model via its "model" field when the user asks for one; the user\'s /subagents choice wins over it, and a model the account is not served is not used. Every result says the model and effort the child ran on.',
248
309
  "Every task description must be SELF-CONTAINED: the child cannot see this conversation.",
249
310
  "Use for parallelizable work: broad code surveys, independent research questions, independent implementation chunks.",
250
311
  "Give each task only its own part of the work: never the user's whole message or instructions about sub-agents (the child would follow them and delegate again).",
@@ -257,7 +318,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
257
318
  "Do not use spawn_agents for a single quick action — doing it yourself is cheaper.",
258
319
  ...(depth > 0 ? [] : [SWARM_GUIDELINE]),
259
320
  ],
260
- parameters: SpawnAgentsParams,
321
+ parameters: spawnAgentsParams(modelExamples(loadCatalog(omniDir()))),
261
322
 
262
323
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
263
324
  const parentSessionId = sessionOf(ctx);
@@ -266,9 +327,9 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
266
327
  if (!raw || typeof raw.task !== "string" || !raw.task.trim()) {
267
328
  throw new Error("invalid tasks: every entry needs a non-empty task string");
268
329
  }
269
- // A task's own model[:effort] wins; an omitted (or empty) one takes
270
- // the /subagents pick, else the parent session's current model and
271
- // effort (resolved below by subagentTasks).
330
+ // A task's own model[:effort] (resolved below by subagentTasks):
331
+ // the user's /subagents choice wins over it unless the user's message
332
+ // names that model; unset, it runs only when the gateway serves it.
272
333
  const requestedModel = typeof raw.model === "string" ? raw.model.trim() : "";
273
334
  tasks.push({
274
335
  role: raw.role,
@@ -277,11 +338,11 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
277
338
  });
278
339
  }
279
340
  if (tasks.length === 0) throw new Error("no tasks given");
280
- // The model and effort each child runs on (subagents.ts): the task's
281
- // own model[:effort], else the /subagents pick, else the parent
341
+ // The model and effort each child runs on (subagents.ts): the
342
+ // /subagents choice, else a served task model, else the parent
282
343
  // session's current model and effort; the main model when a pick is
283
- // unavailable.
284
- const resolvedTasks = subagentTasks(pi, ctx, tasks);
344
+ // unavailable. Overrides are noted on the result and the trace.
345
+ const resolvedTasks = subagentTasks(pi, ctx, tasks, { userPrompt: latestUserPrompt(ctx) });
285
346
  if (!parentSessionId) throw new Error("no parent session id — subagents cannot be traced");
286
347
  const workdir = ctx?.cwd ? String(ctx.cwd) : process.cwd();
287
348
  // Only a deliberate limit counts: models fill optional numbers with
@@ -334,7 +395,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
334
395
 
335
396
  if (background) {
336
397
  const lines = dispatched.records.map((record, index) => {
337
- const note = childModelFallback(resolvedTasks[index])?.note;
398
+ const note = [resolvedTasks[index].override?.note, childModelFallback(resolvedTasks[index])?.note].filter(Boolean).join("; ");
338
399
  return `- ${record.id}: ${record.role}${record.model ? ` [${record.model}${record.effort ? ` · ${record.effort}` : ""}]` : ""} — ${record.task.length > 100 ? `${record.task.slice(0, 99)}…` : record.task}${note ? ` (${note})` : ""}`;
339
400
  });
340
401
  return {
@@ -363,6 +424,9 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
363
424
  ...(record.model ? { model: record.model } : {}),
364
425
  ...(record.effort ? { effort: record.effort } : {}),
365
426
  ...(modelFallback ? { model_fallback: modelFallback } : {}),
427
+ ...(resolvedTasks[index].override ? { model_override: { ...resolvedTasks[index].override } } : {}),
428
+ ...(resolvedTasks[index].modelSource ? { model_source: resolvedTasks[index].modelSource } : {}),
429
+ ...(resolvedTasks[index].requestedModel ? { requested_model: resolvedTasks[index].requestedModel } : {}),
366
430
  parallel_limit: record.parallelLimit,
367
431
  status: "running",
368
432
  };
@@ -5,11 +5,55 @@ const SECRET_ENV_NAMES = new Set([
5
5
  "OMNIRUSH_APPROVAL_TOKEN",
6
6
  ]);
7
7
 
8
- /** Keep provider credentials in the agent process, never in shell/MCP children. */
8
+ /**
9
+ * The agent core's own PI_<NAME> variables (src/compat.js
10
+ * CORE_ONLY_ENV_NAMES; a test keeps the lists equal). Shell commands and
11
+ * MCP servers see their OMNIRUSH_<NAME> copies only; any other PI_*
12
+ * variable (a user's own) passes through.
13
+ */
14
+ export const CORE_ONLY_ENV_NAMES = [
15
+ "CODING_AGENT",
16
+ "CODING_AGENT_DIR",
17
+ "CODING_AGENT_SESSION_DIR",
18
+ "SESSION_ID",
19
+ "SESSION_FILE",
20
+ "PROVIDER",
21
+ "MODEL",
22
+ "REASONING_LEVEL",
23
+ "OFFLINE",
24
+ "TELEMETRY",
25
+ "INSTALLER_API_BASE",
26
+ "MANAGED_INSTALL_ROOT",
27
+ "CACHE_RETENTION",
28
+ "CLEAR_ON_SHRINK",
29
+ "EXPERIMENTAL",
30
+ "HARDWARE_CURSOR",
31
+ "HYPERLINKS",
32
+ "IMAGE_PROTOCOL",
33
+ "OAUTH_CALLBACK_HOST",
34
+ "PACKAGE_DIR",
35
+ "RADIUS_GATEWAY",
36
+ "SHARE_VIEWER_URL",
37
+ "SKIP_VERSION_CHECK",
38
+ "STARTUP_BENCHMARK",
39
+ "TIMING",
40
+ "TRUE_COLOR",
41
+ "TUI_DEBUG",
42
+ "TUI_DEBUG_REDRAW",
43
+ "TUI_ESC_TIMEOUT",
44
+ "TUI_WRITE_LOG",
45
+ ];
46
+ const CORE_ONLY_ENV = new Set(CORE_ONLY_ENV_NAMES.map((name) => `PI_${name}`));
47
+
48
+ /**
49
+ * Keep provider credentials in the agent process, never in shell/MCP
50
+ * children; the core's PI_* variables stay there too (the children get the
51
+ * OMNIRUSH_* spellings).
52
+ */
9
53
  export function sanitizeToolEnvironment(input: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
10
54
  const output: NodeJS.ProcessEnv = {};
11
55
  for (const [key, value] of Object.entries(input)) {
12
- if (!SECRET_ENV_NAMES.has(key) && value !== undefined) output[key] = value;
56
+ if (!SECRET_ENV_NAMES.has(key) && !CORE_ONLY_ENV.has(key) && value !== undefined) output[key] = value;
13
57
  }
14
58
  return output;
15
59
  }
@@ -159,13 +159,48 @@ function modelFallbackOf(result: any): ChildModelFallback | null {
159
159
  };
160
160
  }
161
161
 
162
+ /** A task's own model that did not run (spawn_agents' model_override). */
163
+ export type ChildModelOverride = { requested: string; used: string; effort: string | null; reason: string; note: string };
164
+
165
+ /** The model and effort a sub-agent really ran on, once its result is final. */
166
+ export type ChildRanOn = { model: string | null; effort: string | null; source: string | null; requested: string | null; status: string };
167
+
168
+ function modelOverrideOf(result: any): ChildModelOverride | null {
169
+ const override = result && typeof result.model_override === "object" ? result.model_override : null;
170
+ if (!override || typeof override.requested !== "string" || typeof override.used !== "string") return null;
171
+ return {
172
+ requested: override.requested,
173
+ used: override.used,
174
+ effort: typeof override.effort === "string" ? override.effort : null,
175
+ reason: typeof override.reason === "string" ? override.reason : "user_choice",
176
+ note: typeof override.note === "string" ? override.note : `ran on ${override.used}`,
177
+ };
178
+ }
179
+
180
+ /** A finished child's model and effort (the gateway fallback's when the gateway moved it). */
181
+ function ranOnOf(result: any, fallback: ChildModelFallback | null): ChildRanOn | null {
182
+ const status = result && typeof result.status === "string" ? result.status : "";
183
+ if (!status || status === "running" || status === "queued") return null;
184
+ const gateway = fallback?.kind === "gateway" ? fallback : null;
185
+ return {
186
+ model: gateway ? gateway.used : typeof result.model === "string" ? result.model : null,
187
+ effort: gateway ? gateway.effort : typeof result.effort === "string" ? result.effort : null,
188
+ source: typeof result.model_source === "string" ? result.model_source : null,
189
+ requested: typeof result.requested_model === "string" ? result.requested_model : null,
190
+ status,
191
+ };
192
+ }
193
+
194
+ type SpawnedChild = { id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback; modelOverride?: ChildModelOverride; ranOn?: ChildRanOn };
195
+
162
196
  /**
163
197
  * Sub-agents a session started: its spawn_agents results (session id, role,
164
198
  * task), with the model fallback a later result reported (a background
165
- * child's delivery, agents_wait / agents_result) when there is one.
199
+ * child's delivery, agents_wait / agents_result) when there is one, a task
200
+ * model that did not run, and the model and effort it ran on once finished.
166
201
  */
167
- export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> {
168
- const children: Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> = [];
202
+ export function spawnedChildren(entries: readonly PiEntry[]): SpawnedChild[] {
203
+ const children: SpawnedChild[] = [];
169
204
  for (const entry of entries) {
170
205
  const message = entry.type === "message" ? entry.message : null;
171
206
  const spawned = Boolean(message && message.role === "toolResult" && message.toolName === "spawn_agents");
@@ -182,16 +217,23 @@ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string
182
217
  if (!sanitizeSessionId(id)) continue;
183
218
  const known = children.find((child) => child.id === id);
184
219
  const modelFallback = modelFallbackOf(result);
220
+ const modelOverride = modelOverrideOf(result);
185
221
  if (known) {
186
222
  if (modelFallback) known.modelFallback = modelFallback;
223
+ if (modelOverride) known.modelOverride = modelOverride;
224
+ const ran = ranOnOf(result, known.modelFallback ?? null);
225
+ if (ran) known.ranOn = ran;
187
226
  continue;
188
227
  }
189
228
  if (!spawned) continue;
229
+ const ran = ranOnOf(result, modelFallback);
190
230
  children.push({
191
231
  id,
192
232
  role: typeof result.role === "string" ? result.role : null,
193
233
  task: typeof result.task === "string" ? result.task : null,
194
234
  ...(modelFallback ? { modelFallback } : {}),
235
+ ...(modelOverride ? { modelOverride } : {}),
236
+ ...(ran ? { ranOn: ran } : {}),
195
237
  });
196
238
  }
197
239
  }
@@ -200,6 +242,15 @@ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string
200
242
 
201
243
  /** Fallbacks already recorded as trace events (root session : child session). */
202
244
  const recordedFallbacks = new Set<string>();
245
+ /** Overrides and ran-on models already recorded (same keys). */
246
+ const recordedOverrides = new Set<string>();
247
+ const recordedRanOn = new Set<string>();
248
+
249
+ /** A child's trace title: its task, with the model note when its model was not the one asked for. */
250
+ function childTitle(child: SpawnedChild): string | null {
251
+ const notes = [child.modelOverride?.note, child.modelFallback?.note].filter(Boolean);
252
+ return child.task && notes.length ? `${child.task} (${notes.join("; ")})` : child.task;
253
+ }
203
254
 
204
255
  /** The session file of `sessionId`: `<ts>_<id>.jsonl` in one of the session dirs. */
205
256
  async function findSessionFile(sessionId: string, dirs: readonly string[]): Promise<string | null> {
@@ -369,13 +420,38 @@ export async function captureChildSessions(input: {
369
420
  kind: child.modelFallback.kind,
370
421
  });
371
422
  }
423
+ if (child.modelOverride && !recordedOverrides.has(fallbackKey)) {
424
+ // The task named a model that did not run: the user's /subagents
425
+ // choice won, or the gateway does not serve it to this account.
426
+ recordedOverrides.add(fallbackKey);
427
+ input.sync.recordTrace(input.rootSessionId, "subagent.model_override", {
428
+ child_session_id: child.id,
429
+ requested: child.modelOverride.requested,
430
+ used: child.modelOverride.used,
431
+ effort: child.modelOverride.effort,
432
+ reason: child.modelOverride.reason,
433
+ });
434
+ }
435
+ if (child.ranOn && !recordedRanOn.has(fallbackKey)) {
436
+ // What the sub-agent really ran on (after any gateway fallback).
437
+ recordedRanOn.add(fallbackKey);
438
+ input.sync.recordTrace(input.rootSessionId, "subagent.model", {
439
+ child_session_id: child.id,
440
+ role: child.role,
441
+ model: child.ranOn.model,
442
+ effort: child.ranOn.effort,
443
+ source: child.ranOn.source,
444
+ requested: child.ranOn.requested,
445
+ status: child.ranOn.status,
446
+ });
447
+ }
372
448
  if (history.delta.length > 0 || !input.known.has(child.id)) {
373
449
  await input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
374
450
  input.sync.recordChildSession(input.rootSessionId, {
375
451
  childSessionId: child.id,
376
452
  parentSessionId: input.parentSessionId,
377
453
  depth: input.depth,
378
- title: child.task && child.modelFallback ? `${child.task} (${child.modelFallback.note})` : child.task,
454
+ title: childTitle(child),
379
455
  agent: turnModelFromMessages(messages)?.agent ?? agent,
380
456
  messages: history.delta,
381
457
  lastMessageId: typeof messages.at(-1)?.info.id === "string" ? (messages.at(-1)!.info.id as string) : null,
@@ -12,7 +12,12 @@
12
12
  // agent's model and the main agent's effort.
13
13
  // - spawn_agents (agents.ts) resolves every task against it
14
14
  // (resolveSubagentModel) and starts the child with that --model and
15
- // --thinking; a task's own `model` wins over the picked one.
15
+ // --thinking. The user's choice wins over a task's own `model` (the
16
+ // agent's), which runs only when the user's message names it; with no
17
+ // choice set, a task model runs only when the account's catalog lists
18
+ // it (served), else the delegating agent's model and effort. Every
19
+ // override is on the result and in the trace (subagent.model_override),
20
+ // and every finished child's real model and effort (subagent.model).
16
21
  // - A picked model that is not in the account's catalog (the list the
17
22
  // launcher fetched from GET /v1/models), or that the gateway refused in
18
23
  // the last five minutes, resolves to the main model instead (a
@@ -134,7 +139,9 @@ export function catalogFromPayload(payload: unknown): CatalogModel[] | null {
134
139
  const ownership = [entry.provider, entry.owned_by, entry.owner, entry.source, entry.namespace, entry.managed_by]
135
140
  .filter((value): value is string => typeof value === "string")
136
141
  .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"];
142
+ // Ids the gateway serves under their bare names; an id counts as served
143
+ // only when this payload lists it (never because the CLI ships it).
144
+ const shipped = ["gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol", "meta-muse-spark", "muse-spark-1.1"];
138
145
  if (!shipped.includes(id) && entry.omnirush !== true && !ownership.includes("omnirush")) continue;
139
146
  const levels = Array.isArray(entry.reasoning_levels)
140
147
  ? EFFORTS.filter((effort) => entry.reasoning_levels.some((level: unknown) => parseEffort(level) === effort))
@@ -323,6 +330,26 @@ export interface SubagentFallback {
323
330
  reason: FallbackReason | string;
324
331
  }
325
332
 
333
+ /** Why a task's own `model` did not run: the user's /subagents choice, or the gateway does not serve it. */
334
+ export type OverrideReason = "user_choice" | "not_served" | "catalog_unavailable" | "refused";
335
+
336
+ /** A task's own `model[:effort]` that was not used, and what ran instead. */
337
+ export interface SubagentOverride {
338
+ /** The task's model[:effort] as the agent wrote it. */
339
+ requested: string;
340
+ used: string;
341
+ effort: string | null;
342
+ reason: OverrideReason;
343
+ }
344
+
345
+ /**
346
+ * Where a sub-agent's model comes from: "subagents" (the user's /subagents
347
+ * choice), "user_prompt" (a task model the user's own message named),
348
+ * "task" (a task model the gateway serves, /subagents unset) or "parent"
349
+ * (the delegating agent's model and effort).
350
+ */
351
+ export type ModelSource = "subagents" | "user_prompt" | "task" | "parent";
352
+
326
353
  export interface SubagentResolution {
327
354
  /** The model the child runs on; null = leave the child to pi's own default (no omnirush model known). */
328
355
  model: string | null;
@@ -332,6 +359,10 @@ export interface SubagentResolution {
332
359
  fallback?: SubagentFallback;
333
360
  /** The main model (and effort) the child's gateway guard moves to when the gateway refuses `model`. */
334
361
  gatewayFallback?: { model: string; effort: string | null };
362
+ /** The task named a model that did not run (the user's choice won, or it is not served). */
363
+ override?: SubagentOverride;
364
+ /** Where the model comes from (absent when no omnirush model is known). */
365
+ source?: ModelSource;
335
366
  }
336
367
 
337
368
  export interface AgentModel {
@@ -342,14 +373,33 @@ export interface AgentModel {
342
373
  effort: string | null;
343
374
  }
344
375
 
376
+ /** Whether `text` names the model id `id` (case-insensitive, as a whole id: "gpt-6-sol" is not named by "gpt-6-sol-mini" or "gpt-5.6-sol"). */
377
+ export function textNamesModel(text: string | null | undefined, id: string): boolean {
378
+ if (typeof text !== "string" || !text || !id) return false;
379
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
380
+ return new RegExp(`(?<![A-Za-z0-9._-])${escaped}(?![A-Za-z0-9_-]|\\.[A-Za-z0-9])`, "i").test(text);
381
+ }
382
+
383
+ /** Whether the gateway serves `model` to this account: the live catalog lists it. */
384
+ export function isServed(catalog: readonly CatalogModel[] | null, model: string): boolean {
385
+ return Boolean(catalog?.some((entry) => entry.id === model));
386
+ }
387
+
345
388
  /**
346
389
  * What one sub-agent runs on.
347
390
  *
348
391
  * `delegating` is the agent calling spawn_agents (its model is what "same as
349
392
  * main" keeps); `main` is the main session's model and effort (handed down
350
393
  * to nested layers through the environment), the one a picked model falls
351
- * back to. `explicit` is the task's own `model`, which wins over the picked
352
- * one and is not checked against the catalog (the gateway decides).
394
+ * back to. `explicit` is the task's own `model[:effort]` (the agent's
395
+ * choice) and `userPrompt` the user's latest message:
396
+ * - the user set /subagents (model and/or effort): that choice wins; a
397
+ * task model runs only when the user's own message names it (and the
398
+ * gateway serves it), otherwise it is overridden ("user_choice");
399
+ * - /subagents unset: a task model runs only when the account's live
400
+ * catalog lists it (and the gateway did not refuse it a moment ago);
401
+ * otherwise the delegating agent's model and the main effort run
402
+ * ("not_served", "catalog_unavailable" or "refused").
353
403
  */
354
404
  export function resolveSubagentModel(input: {
355
405
  setting: SubagentSetting;
@@ -357,6 +407,7 @@ export function resolveSubagentModel(input: {
357
407
  delegating: AgentModel;
358
408
  main?: AgentModel | null;
359
409
  explicit?: string | null;
410
+ userPrompt?: string | null;
360
411
  refused?: (model: string) => boolean;
361
412
  }): SubagentResolution {
362
413
  const { setting, catalog, delegating } = input;
@@ -364,6 +415,7 @@ export function resolveSubagentModel(input: {
364
415
  const mainIsGateway = main.provider === PROVIDER_ID && Boolean(main.model);
365
416
  const wantedEffort = setting.effort ?? parseEffort(main.effort) ?? null;
366
417
  const effortOn = (model: string): string | null => nearestEffort(wantedEffort, levelsFor(catalog, model));
418
+ const userChose = Boolean(setting.model || setting.effort);
367
419
 
368
420
  const withGatewayFallback = (resolution: SubagentResolution): SubagentResolution => {
369
421
  if (
@@ -377,21 +429,39 @@ export function resolveSubagentModel(input: {
377
429
  return resolution;
378
430
  };
379
431
 
380
- // The task's own model[:effort]: an effort it names wins over the picked
381
- // and the parent's (mapped to the nearest level the model offers).
382
- const explicit = input.explicit && isModelId(input.explicit) ? splitModelEffort(input.explicit) : null;
432
+ // The task's own model[:effort].
433
+ const requested = typeof input.explicit === "string" ? input.explicit.trim() : "";
434
+ const explicit = requested && isModelId(requested) ? splitModelEffort(requested) : null;
435
+ let overrideReason: OverrideReason | null = null;
383
436
  if (explicit && isModelId(explicit.model)) {
384
- const effort = explicit.effort ? nearestEffort(explicit.effort, levelsFor(catalog, explicit.model)) ?? explicit.effort : effortOn(explicit.model);
385
- return withGatewayFallback({ model: explicit.model, effort });
437
+ const served = isServed(catalog, explicit.model);
438
+ const refusedNow = served && Boolean(input.refused?.(explicit.model));
439
+ const named = served && textNamesModel(input.userPrompt, explicit.model);
440
+ const allowed = served && !refusedNow && (userChose ? named : true);
441
+ if (allowed) {
442
+ // An effort the task names applies unless the user picked one with /subagents.
443
+ const taskEffort = explicit.effort && !setting.effort ? explicit.effort : null;
444
+ const effort = taskEffort ? nearestEffort(taskEffort, levelsFor(catalog, explicit.model)) ?? taskEffort : effortOn(explicit.model);
445
+ return withGatewayFallback({ model: explicit.model, effort, source: named ? "user_prompt" : "task" });
446
+ }
447
+ overrideReason = userChose ? "user_choice" : !catalog ? "catalog_unavailable" : !served ? "not_served" : "refused";
386
448
  }
449
+ const withOverride = (resolution: SubagentResolution): SubagentResolution => {
450
+ if (!overrideReason || !explicit || !resolution.model) return resolution;
451
+ // The same model at the same effort is no override.
452
+ if (explicit.model === resolution.model && (!explicit.effort || explicit.effort === resolution.effort)) return resolution;
453
+ resolution.override = { requested, used: resolution.model, effort: resolution.effort, reason: overrideReason };
454
+ return resolution;
455
+ };
387
456
 
388
457
  if (setting.model) {
389
458
  const inCatalog = !catalog || catalog.some((entry) => entry.id === setting.model);
390
459
  const reason: FallbackReason | null = !inCatalog ? "not_in_catalog" : input.refused?.(setting.model) ? "refused" : null;
391
460
  if (reason && mainIsGateway && main.model !== setting.model) {
392
- return {
461
+ return withOverride({
393
462
  model: main.model!,
394
463
  effort: effortOn(main.model!),
464
+ source: "subagents",
395
465
  fallback: {
396
466
  requested: setting.model,
397
467
  requestedName: displayName(catalog, setting.model),
@@ -399,16 +469,46 @@ export function resolveSubagentModel(input: {
399
469
  usedName: displayName(catalog, main.model!),
400
470
  reason,
401
471
  },
402
- };
472
+ });
403
473
  }
404
- return withGatewayFallback({ model: setting.model, effort: effortOn(setting.model) });
474
+ return withOverride(withGatewayFallback({ model: setting.model, effort: effortOn(setting.model), source: "subagents" }));
405
475
  }
406
476
 
407
477
  // "Same as main": the delegating agent's own model.
408
478
  if (delegating.provider !== PROVIDER_ID || !delegating.model) {
409
479
  return { model: null, effort: null };
410
480
  }
411
- return withGatewayFallback({ model: delegating.model, effort: effortOn(delegating.model) });
481
+ return withOverride(withGatewayFallback({
482
+ model: delegating.model,
483
+ effort: effortOn(delegating.model),
484
+ source: userChose ? "subagents" : "parent",
485
+ }));
486
+ }
487
+
488
+ /** A readable note for a task model that did not run (for the parent's result and the trace title). */
489
+ export function overrideNote(override: SubagentOverride, catalog: readonly CatalogModel[] | null = null): string {
490
+ const ran = `${displayName(catalog, override.used)}${override.effort ? ` · ${override.effort}` : ""}`;
491
+ switch (override.reason) {
492
+ case "user_choice":
493
+ return `ran on ${ran}, your /subagents choice (${override.requested} was asked for)`;
494
+ case "not_served":
495
+ return `ran on ${ran}, the parent's model: ${override.requested} is not served to this account`;
496
+ case "catalog_unavailable":
497
+ return `ran on ${ran}, the parent's model: the account's model list is not available to check ${override.requested}`;
498
+ default:
499
+ return `ran on ${ran}, the parent's model: ${override.requested} was refused by omnirush.ai recently`;
500
+ }
501
+ }
502
+
503
+ /** How a sub-agent's model was chosen, in words (the result the parent reads). */
504
+ export function sourceNote(source: ModelSource | string | undefined): string {
505
+ switch (source) {
506
+ case "subagents": return "your /subagents choice";
507
+ case "user_prompt": return "named in your message";
508
+ case "task": return "the task's model";
509
+ case "parent": return "same as the parent";
510
+ default: return "";
511
+ }
412
512
  }
413
513
 
414
514
  /** A readable note for a sub-agent that ran on the main model instead of the picked one. */
@@ -31,6 +31,7 @@ import {
31
31
  loadCatalog,
32
32
  nearestEffort,
33
33
  nextSetting,
34
+ overrideNote,
34
35
  parseEffort,
35
36
  PROVIDER_ID,
36
37
  readSetting,
@@ -115,7 +116,12 @@ export function mainModel(pi: any, ctx: any, env: NodeJS.ProcessEnv = process.en
115
116
  * model when the picked one is not available, and the environment that hands
116
117
  * the setting and the main model down to nested layers.
117
118
  */
118
- export function subagentTasks(pi: any, ctx: any, tasks: ChildTask[], options: { dir?: string; env?: NodeJS.ProcessEnv; now?: number } = {}): ChildTask[] {
119
+ export function subagentTasks(
120
+ pi: any,
121
+ ctx: any,
122
+ tasks: ChildTask[],
123
+ options: { dir?: string; env?: NodeJS.ProcessEnv; now?: number; userPrompt?: string | null } = {},
124
+ ): ChildTask[] {
119
125
  const dir = options.dir ?? omniDir();
120
126
  const env = options.env ?? process.env;
121
127
  const { setting } = currentSetting(pi, dir);
@@ -137,12 +143,17 @@ export function subagentTasks(pi: any, ctx: any, tasks: ChildTask[], options: {
137
143
  delegating,
138
144
  main,
139
145
  explicit: task.model ?? null,
146
+ userPrompt: options.userPrompt ?? null,
140
147
  refused: (model) => Boolean(isRefused(dir, model, options.now)),
141
148
  });
142
149
  const next: ChildTask = { ...task, env: { ...(task.env ?? {}), ...handDown } };
150
+ if (task.model) next.requestedModel = task.model;
143
151
  if (resolution.model) next.model = resolution.model;
144
152
  else delete next.model;
145
153
  if (resolution.model && resolution.effort) next.effort = resolution.effort;
154
+ else delete next.effort;
155
+ if (resolution.source) next.modelSource = resolution.source;
156
+ if (resolution.override) next.override = { ...resolution.override, note: overrideNote(resolution.override, catalog) };
146
157
  if (resolution.fallback) next.fallback = resolution.fallback;
147
158
  if (resolution.gatewayFallback) next.gatewayFallback = resolution.gatewayFallback;
148
159
  return next;
@@ -292,7 +303,7 @@ export default function (pi: any) {
292
303
  if (patch.model !== undefined) override.model = next.model;
293
304
  if (patch.effort !== undefined || next.effort !== current.effort) override.effort = next.effort;
294
305
  sessionOverrides.set(pi, override);
295
- if (patch.model !== undefined && current.effort && next.effort === null) {
306
+ if (command !== "reset" && patch.model !== undefined && current.effort && next.effort === null) {
296
307
  notify(`Sub-agent effort ${current.effort} is not offered by ${next.model ? displayName(catalog, next.model) : "the main model"}: back to same as main.`, "warning");
297
308
  }
298
309
  if (next.effort && next.model === null && main.model) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnirush",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "Omnirush — free daily tokens for the most powerful coding model on earth.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -23,10 +23,16 @@
23
23
  // folders), OMNIRUSH_* copies of the env the core exports to shell
24
24
  // commands, AI_AGENT, the HTTP User-Agent and temp-file names.
25
25
  //
26
+ // 4. <package>/engine, a link to the core (src/compat.js
27
+ // ensureEngineLink): the launcher runs the core through it, so process
28
+ // listings and the doc paths in the system prompt name omnirush, not
29
+ // the core's npm package.
30
+ //
26
31
  // Not renamed (internal, never shown): package ids and imports, code
27
32
  // identifiers, session entry/customType ids, telemetry attribute keys,
28
33
  // protocol values third-party providers expect, and the PI_* env names the
29
- // core reads (the launcher maps OMNIRUSH_* spellings onto them).
34
+ // core reads (the launcher maps OMNIRUSH_* spellings onto them; shells the
35
+ // core starts get the OMNIRUSH_* copies only).
30
36
  //
31
37
  // Maintenance: after bumping the core, run `node scripts/brand-engine.js`
32
38
  // and `node --test test/branding.test.js`; a target that moved is reported
@@ -38,11 +44,16 @@ import fs from "node:fs";
38
44
  import path from "node:path";
39
45
  import { fileURLToPath } from "node:url";
40
46
 
47
+ import { CORE_ONLY_ENV_NAMES, ensureEngineLink } from "../src/compat.js";
48
+
41
49
  const PKG = "@earendil-works/pi-coding-agent";
42
50
  const BRAND = "omnirush";
43
51
  export const BRAND_CONFIG_DIR = ".omnirush";
44
52
  const OUR_CHANGELOG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "CHANGELOG.md");
45
53
 
54
+ /** Matches the core's own PI_<NAME> variables (src/compat.js CORE_ONLY_ENV_NAMES), as JS regex source. */
55
+ export const CORE_ENV_PATTERN = `/^PI_(${CORE_ONLY_ENV_NAMES.join("|")})$/`;
56
+
46
57
  // `from` must match the installed dist exactly; applied with replaceAll to
47
58
  // every dist/**/*.js. A `to` must never contain its own `from` (idempotency).
48
59
  export const REPLACEMENTS = [
@@ -157,13 +168,37 @@ export const REPLACEMENTS = [
157
168
  {
158
169
  label: "/bug disclaimer",
159
170
  from: "This report goes to the Pi developers (Earendil) and is not shared publicly. It includes your pi version,",
160
- to: "This report goes to the agent core's upstream developers (Earendil) and is not shared publicly. It includes your omnirush version,",
171
+ to: "This report is not shared publicly. It includes your omnirush version,",
161
172
  },
162
173
  {
163
174
  label: "announcement easter egg",
164
175
  from: '"pi has joined Earendil"',
165
- to: '"The agent core has joined Earendil"',
176
+ to: '"omnirush"',
177
+ },
178
+ {
179
+ // Upstream easter eggs typed as commands (/arminsayshi, /dementedelves;
180
+ // the third only fires for one third-party model): shown nothing.
181
+ label: "hidden /arminsayshi is inert (bundle)",
182
+ from: "handleArminSaysHi(){",
183
+ to: "handleArminSaysHi(_off){return;",
184
+ },
185
+ {
186
+ label: "hidden /dementedelves is inert (bundle)",
187
+ from: "handleDementedDelves(){",
188
+ to: "handleDementedDelves(_off){return;",
166
189
  },
190
+ { label: "upstream easter egg is inert (bundle)", from: "handleDaxnuts(){", to: "handleDaxnuts(_off){return;" },
191
+ {
192
+ label: "hidden /arminsayshi is inert (dist)",
193
+ from: " handleArminSaysHi() {",
194
+ to: " handleArminSaysHi(_off) { return;",
195
+ },
196
+ {
197
+ label: "hidden /dementedelves is inert (dist)",
198
+ from: " handleDementedDelves() {",
199
+ to: " handleDementedDelves(_off) { return;",
200
+ },
201
+ { label: "upstream easter egg is inert (dist)", from: " handleDaxnuts() {", to: " handleDaxnuts(_off) { return;" },
167
202
  {
168
203
  label: "external editor note",
169
204
  from: "Pi will resume when the editor exits.",
@@ -345,7 +380,20 @@ export const REPLACEMENTS = [
345
380
  label: "OMNIRUSH_* session env for shell commands",
346
381
  from: "ctx.thinkingLevel&&(env2.PI_REASONING_LEVEL=ctx.thinkingLevel)}let baseContext={command,cwd,env:env2}",
347
382
  to:
348
- 'ctx.thinkingLevel&&(env2.PI_REASONING_LEVEL=ctx.thinkingLevel)}for(let k of["SESSION_ID","SESSION_FILE","PROVIDER","MODEL","REASONING_LEVEL"])env2["PI_"+k]===void 0?delete env2["OMNIRUSH_"+k]:env2["OMNIRUSH_"+k]=env2["PI_"+k];let baseContext={command,cwd,env:env2}',
383
+ 'ctx.thinkingLevel&&(env2.PI_REASONING_LEVEL=ctx.thinkingLevel)}for(let k of["SESSION_ID","SESSION_FILE","PROVIDER","MODEL","REASONING_LEVEL"])env2["PI_"+k]===void 0?delete env2["OMNIRUSH_"+k]:(env2["OMNIRUSH_"+k]=env2["PI_"+k],delete env2["PI_"+k]);let baseContext={command,cwd,env:env2}',
384
+ },
385
+ {
386
+ // The env every shell the core starts inherits (the bash tool, `!`
387
+ // commands, package installs): the core's own PI_* variables stay in
388
+ // the agent process; their OMNIRUSH_* copies are what commands see.
389
+ label: "no core PI_* variables in shell commands (bundle)",
390
+ from: "return{...process.env,[pathKey]:updatedPath}}function sanitizeBinaryOutput",
391
+ to: `let _env={...process.env,[pathKey]:updatedPath};for(let k of Object.keys(_env))${CORE_ENV_PATTERN}.test(k)&&delete _env[k];return _env}function sanitizeBinaryOutput`,
392
+ },
393
+ {
394
+ label: "no core PI_* variables in shell commands (dist)",
395
+ from: " return {\n ...process.env,\n [pathKey]: updatedPath,\n };\n}",
396
+ to: ` const _env = { ...process.env, [pathKey]: updatedPath };\n for (const k of Object.keys(_env)) if (${CORE_ENV_PATTERN}.test(k)) delete _env[k];\n return _env;\n}`,
349
397
  },
350
398
 
351
399
  // --- HTTP User-Agent (the omnirush gateway sees it) --------------------
@@ -377,6 +425,9 @@ export const REPLACEMENTS = [
377
425
  { label: "bug report zip", from: "`pi-bug-report-${id}.zip`", to: "`omnirush-bug-report-${id}.zip`" },
378
426
  { label: "TUI crash log", from: '"pi-tui-crash.log"', to: '"omnirush-tui-crash.log"' },
379
427
  { label: "TUI debug log", from: '"pi-tui-debug.log"', to: '"omnirush-tui-debug.log"' },
428
+ { label: "git package update marker", from: ".pi-update-incomplete", to: ".omnirush-update-incomplete" },
429
+ { label: "native module quarantine dir", from: '".pi-native-quarantine"', to: '".omnirush-native-quarantine"' },
430
+ { label: "package install root package.json name", from: '"pi-extensions"', to: '"omnirush-extensions"' },
380
431
 
381
432
  // --- update banner (belt-and-braces; the launcher sets PI_OFFLINE) -----
382
433
  // showNewVersionNotification returns before its body (the parameter is
@@ -507,6 +558,9 @@ function main() {
507
558
  console.log("omnirush: agent core not installed; skipped branding patch");
508
559
  return;
509
560
  }
561
+ // <package>/engine -> the core (what process listings and the system
562
+ // prompt's doc paths show; the launcher re-creates it when missing).
563
+ ensureEngineLink(repoRoot, report.piRoot);
510
564
  const patched = report.packageJsonPatched ? "package.json + " : "";
511
565
  const notes = report.changelogReplaced ? " + release notes" : "";
512
566
  console.log(
package/src/bin.js CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  yoloBanner,
55
55
  } from "./lib.js";
56
56
  import { installManagedTools } from "./tools.js";
57
- import { engineEnvAliases, migrateAgentDir, projectConfigDirName, resolveAgentDir } from "./compat.js";
57
+ import { engineEnvAliases, ensureEngineLink, migrateAgentDir, projectConfigDirName, resolveAgentDir } from "./compat.js";
58
58
  import { customSessionDir, moveLegacySubagentSessions } from "./sessions.js";
59
59
  import { ensureRuntime, managedBunPath } from "../scripts/postinstall.js";
60
60
  import {
@@ -144,12 +144,31 @@ function piPackage() {
144
144
  return null;
145
145
  }
146
146
 
147
+ // The core as users see it (process listings, the doc paths in the system
148
+ // prompt, sub-agent command lines): <package>/engine, a link to the core's
149
+ // folder (src/compat.js ensureEngineLink); null when the link cannot be made.
150
+ let ENGINE_LINK_DIR = null;
151
+ let ENGINE_ENTRY = null;
152
+
147
153
  function piEntry() {
148
154
  const found = piPackage();
149
- if (found?.pkg?.bin?.pi) return path.join(found.dir, found.pkg.bin.pi);
155
+ if (found?.pkg?.bin?.pi) {
156
+ const dir = ensureEngineLink(path.resolve(__dirname, ".."), found.dir);
157
+ ENGINE_LINK_DIR = dir === path.resolve(found.dir) ? null : dir;
158
+ ENGINE_ENTRY = path.join(dir, found.pkg.bin.pi);
159
+ return ENGINE_ENTRY;
160
+ }
150
161
  throw new Error("could not locate the pinned agent core (broken install). Fix: npm install -g omnirush@latest");
151
162
  }
152
163
 
164
+ /** The core's package dir as the link, unless the user points it elsewhere. */
165
+ function engineDirEnv(env = process.env) {
166
+ const env2 = {};
167
+ if (ENGINE_ENTRY) env2.OMNIRUSH_CORE_ENTRY = ENGINE_ENTRY; // sub-agents start the core through the same path
168
+ if (ENGINE_LINK_DIR && !env.PI_PACKAGE_DIR && !env.OMNIRUSH_PACKAGE_DIR) env2.PI_PACKAGE_DIR = ENGINE_LINK_DIR;
169
+ return env2;
170
+ }
171
+
153
172
  function ensureDirs() {
154
173
  fs.mkdirSync(OMNI_DIR, { recursive: true, mode: 0o700 });
155
174
  migrateLegacyAgentData();
@@ -875,6 +894,7 @@ function childEnv(auth) {
875
894
  const origin = (process.env.OMNIRUSH_ORIGIN || "").trim().replace(/\/+$/, "") ||
876
895
  gateway.replace(/\/+$/, "").replace(/\/v1$/, ""); // provider base -> manager origin
877
896
  return {
897
+ ...engineDirEnv(process.env),
878
898
  // OMNIRUSH_* spellings of the core's own settings (PI_* still work).
879
899
  ...engineEnvAliases(process.env),
880
900
  // Bootstrap values only: the agent (and every sub-agent it spawns)
@@ -1090,8 +1110,7 @@ Environment:
1090
1110
  (default 50% of RAM), and none start below this
1091
1111
  (default 10%); e.g. 25% or 6G. OMNIRUSH_AGENT_MEMORY=off
1092
1112
  starts them without checking memory
1093
- OMNIRUSH_<SETTING> the agent core's own settings (e.g. OMNIRUSH_CACHE_RETENTION;
1094
- the older PI_<SETTING> names still work)
1113
+ OMNIRUSH_<SETTING> the agent core's own settings (e.g. OMNIRUSH_CACHE_RETENTION)
1095
1114
 
1096
1115
  Docs: https://omnirush.ai/omnirush — state is stored 0600 in ~/.omnirush`);
1097
1116
  }
package/src/compat.js CHANGED
@@ -270,3 +270,68 @@ export function engineEnvAliases(env = process.env) {
270
270
  }
271
271
  return out;
272
272
  }
273
+
274
+ /**
275
+ * The core's own variables the launcher and the core set for themselves
276
+ * (PI_<NAME>). Shell commands the agent runs, background jobs and MCP
277
+ * servers see their OMNIRUSH_<NAME> copies only; a PI_ variable that is
278
+ * not on this list (a user's own) passes through. Mirrored in
279
+ * scripts/brand-engine.js (the core's shell env) and
280
+ * assets/extensions/omnirush/secret-env.ts (tests keep them equal).
281
+ */
282
+ export const CORE_ONLY_ENV_NAMES = [
283
+ "CODING_AGENT",
284
+ "CODING_AGENT_DIR",
285
+ "CODING_AGENT_SESSION_DIR",
286
+ "SESSION_ID",
287
+ "SESSION_FILE",
288
+ "PROVIDER",
289
+ "MODEL",
290
+ "REASONING_LEVEL",
291
+ "OFFLINE",
292
+ "TELEMETRY",
293
+ "INSTALLER_API_BASE",
294
+ "MANAGED_INSTALL_ROOT",
295
+ ...ENGINE_ENV_NAMES,
296
+ ];
297
+
298
+ /** Folder, in the omnirush package, that links to the installed agent core. */
299
+ export const ENGINE_LINK = "engine";
300
+
301
+ /**
302
+ * The agent core as users see it: <package root>/engine, a link to the
303
+ * core's folder in node_modules (a junction on Windows). The launcher runs
304
+ * the core through it and hands it over as the core's package dir, so
305
+ * process listings, the doc paths in the system prompt and the sub-agent
306
+ * command lines name omnirush instead of the core's npm package. Made by
307
+ * the branding step at install and, when missing (archives, a read-only
308
+ * reinstall), here at launch. Never replaces a real folder. Returns the
309
+ * link, or the core's own folder when the link cannot be made.
310
+ */
311
+ export function ensureEngineLink(packageRoot, coreDir, { fsImpl = fs, platform = process.platform } = {}) {
312
+ const link = path.join(packageRoot, ENGINE_LINK);
313
+ const real = path.resolve(coreDir);
314
+ const points = () => {
315
+ try {
316
+ return fsImpl.realpathSync(link) === fsImpl.realpathSync(real);
317
+ } catch {
318
+ return false;
319
+ }
320
+ };
321
+ if (points()) return link;
322
+ try {
323
+ let stat = null;
324
+ try {
325
+ stat = fsImpl.lstatSync(link);
326
+ } catch {
327
+ /* absent */
328
+ }
329
+ if (stat && !stat.isSymbolicLink()) return real;
330
+ if (stat) fsImpl.unlinkSync(link); // a stale link (the core moved)
331
+ if (platform === "win32") fsImpl.symlinkSync(real, link, "junction");
332
+ else fsImpl.symlinkSync(path.relative(path.dirname(link), real) || ".", link, "dir");
333
+ } catch {
334
+ /* read-only install or a concurrent launch: checked below */
335
+ }
336
+ return points() ? link : real;
337
+ }