omnirush 0.8.6 → 0.9.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.
Files changed (47) hide show
  1. package/assets/CHANGELOG.md +85 -0
  2. package/assets/extensions/omnirush/agents-lib.ts +134 -13
  3. package/assets/extensions/omnirush/agents.ts +51 -107
  4. package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
  5. package/assets/extensions/omnirush/bgshell.ts +392 -0
  6. package/assets/extensions/omnirush/collector.ts +72 -11
  7. package/assets/extensions/omnirush/commands.ts +2 -0
  8. package/assets/extensions/omnirush/deliveries.ts +145 -0
  9. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  10. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  11. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  12. package/assets/extensions/omnirush/guard.ts +340 -0
  13. package/assets/extensions/omnirush/index.ts +12 -0
  14. package/assets/extensions/omnirush/pi-engine.ts +65 -1
  15. package/assets/extensions/omnirush/sota.ts +52 -4
  16. package/assets/extensions/omnirush/status-lib.ts +3 -0
  17. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  18. package/assets/extensions/omnirush/subagents.ts +305 -0
  19. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  20. package/assets/extensions/omnirush/swarm.ts +95 -0
  21. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  22. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  23. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  24. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  25. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  26. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  27. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  28. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  29. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  30. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  31. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  32. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  33. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  34. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  35. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  36. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  37. package/assets/extensions/omnirush/voice.ts +838 -0
  38. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  39. package/assets/extensions/omnirush/yolo.ts +85 -0
  40. package/package.json +7 -3
  41. package/scripts/brand-engine.js +526 -0
  42. package/scripts/build-all-packages.py +29 -1
  43. package/scripts/smoke-packages.py +32 -1
  44. package/src/bin.js +205 -33
  45. package/src/compat.js +272 -0
  46. package/src/lib.js +64 -0
  47. package/scripts/patch-pi-branding.js +0 -251
@@ -0,0 +1,392 @@
1
+ // omnirush-bgshell — long shell commands move to the background instead of
2
+ // being killed.
3
+ //
4
+ // Overrides the built-in bash tool (same execution, output handling and
5
+ // rendering: pi's own bash tool definition runs each call, with an
6
+ // execution backend that keeps the process in a job — bgshell-lib.ts):
7
+ // - a command that finishes within its foreground window (the call's
8
+ // `timeout` in seconds, default 300, OMNIRUSH_BASH_FOREGROUND_SECONDS)
9
+ // answers the call as before;
10
+ // - one still running then is NOT killed: it moves to the background and
11
+ // the call returns its id and the output so far; `run_in_background:
12
+ // true` does that at once (servers, watchers, long builds);
13
+ // - bash_output (new output and status), bash_wait and bash_kill manage
14
+ // background commands; when one finishes, its result comes back to the
15
+ // main agent as a message, the way background sub-agents' results do
16
+ // (deliveries.ts): steered into a running turn, starting a turn when the
17
+ // agent is idle, and holding a one-shot run open until it arrives;
18
+ // - background commands still running when the session ends (exit, /new,
19
+ // /resume) are stopped with a message saying so; their output is kept
20
+ // in the session (and so in the trace) as a bash tool part.
21
+
22
+ import { createBashToolDefinition, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
23
+ import { writeSync } from "node:fs";
24
+ import { Type } from "typebox";
25
+
26
+ import {
27
+ BGSHELL_HOOK,
28
+ type BgShellHook,
29
+ DEFAULT_FOREGROUND_SECONDS,
30
+ jobDetails,
31
+ jobOutcome,
32
+ MAX_FOREGROUND_SECONDS,
33
+ MAX_WAIT_SECONDS,
34
+ renderJobDelivery,
35
+ renderJobResult,
36
+ renderJobStatus,
37
+ shortCommand,
38
+ type ShellExec,
39
+ type ShellJob,
40
+ ShellJobManager,
41
+ tailText,
42
+ } from "./bgshell-lib";
43
+ import { deliveryHub } from "./deliveries";
44
+ import { BACKGROUND_BASH_TYPE } from "./pi-engine";
45
+
46
+ /** customType of the message that brings finished background commands back (the trace maps it to bash tool parts). */
47
+ export const BASH_DELIVERY_TYPE = BACKGROUND_BASH_TYPE;
48
+ /** customType of the one-shot reminder about background commands still running. */
49
+ export const BASH_REMINDER_TYPE = "omnirush-bash-reminder";
50
+
51
+ class MovedToBackground extends Error {
52
+ constructor() {
53
+ super("moved to the background");
54
+ }
55
+ }
56
+
57
+ /** The default foreground window (seconds), OMNIRUSH_BASH_FOREGROUND_SECONDS overriding it. */
58
+ export function defaultForegroundSeconds(env: NodeJS.ProcessEnv = process.env): number {
59
+ const raw = Number(String(env.OMNIRUSH_BASH_FOREGROUND_SECONDS ?? "").trim());
60
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw, MAX_FOREGROUND_SECONDS) : DEFAULT_FOREGROUND_SECONDS;
61
+ }
62
+
63
+ /** The foreground window of one call: its `timeout` (seconds), bounded; placeholders (<= 0) take the default. */
64
+ export function foregroundSeconds(timeout: unknown, fallback: number = defaultForegroundSeconds()): number {
65
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) return fallback;
66
+ return Math.min(Math.max(timeout, 1), MAX_FOREGROUND_SECONDS);
67
+ }
68
+
69
+ function plural(count: number, word: string): string {
70
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
71
+ }
72
+
73
+ export default function (pi: any, options: { exec?: ShellExec; now?: () => number } = {}) {
74
+ const hub = deliveryHub(pi);
75
+ const exec: ShellExec = options.exec ?? (createLocalBashOperations().exec as ShellExec);
76
+ const manager = new ShellJobManager({ exec, ...(options.now ? { now: options.now } : {}), onDeliverable: () => hub.notify() });
77
+ const fgDefault = defaultForegroundSeconds();
78
+
79
+ /** New output of each job, marked read. */
80
+ const collect = (jobs: ShellJob[]) => jobs.map((job) => ({ job, ...manager.read(job) }));
81
+
82
+ hub.addSource({
83
+ hasPending: (session) => manager.hasPending(session),
84
+ take: (session) => {
85
+ const jobs = manager.takeDeliverable(session);
86
+ if (jobs.length === 0) return [];
87
+ const parts = collect(jobs);
88
+ return [{
89
+ customType: BASH_DELIVERY_TYPE,
90
+ content: renderJobDelivery(parts),
91
+ details: {
92
+ jobs: parts.map(({ job, text, dropped }) => {
93
+ const shown = tailText(text);
94
+ return jobDetails(job, shown.text, shown.truncated || dropped > 0);
95
+ }),
96
+ },
97
+ }];
98
+ },
99
+ changed: () => manager.changed(),
100
+ beforeEnd: (session) => {
101
+ const jobs = manager.remindable(session);
102
+ if (jobs.length === 0) return null;
103
+ return {
104
+ customType: BASH_REMINDER_TYPE,
105
+ content: [
106
+ `This run is about to end while ${plural(jobs.length, "background command")} you started still run${jobs.length === 1 ? "s" : ""}:`,
107
+ renderJobStatus(jobs),
108
+ "If you need their results, wait for them with bash_wait; stop the ones you no longer need (servers, watchers) with bash_kill. Background commands still running when you finish are stopped.",
109
+ ].join("\n"),
110
+ details: { jobs: jobs.map((job) => job.id) },
111
+ };
112
+ },
113
+ });
114
+
115
+ // --- stopping at the end of the session -------------------------------------
116
+
117
+ /** What the session's end stopped (the collector's hook may do it before our own handler runs). */
118
+ let stoppedAtEnd: ShellJob[] = [];
119
+ const stopAll = async (session?: string): Promise<ShellJob[]> => {
120
+ const live = manager.running(session);
121
+ if (live.length === 0) return [];
122
+ const stopped = await manager.kill(live, { interrupted: true });
123
+ stoppedAtEnd.push(...stopped);
124
+ // Kept in the session (and so in the trace) like any tool output; the
125
+ // model never sees it — nothing runs after this.
126
+ const background = stopped.filter((job) => job.background);
127
+ if (background.length > 0) {
128
+ try {
129
+ const parts = collect(background);
130
+ pi.sendMessage(
131
+ {
132
+ customType: BASH_DELIVERY_TYPE,
133
+ content: [
134
+ `The session ended: ${plural(background.length, "background command")} stopped. Output since it was last read:`,
135
+ "",
136
+ parts.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text).join("\n\n---\n\n"),
137
+ ].join("\n"),
138
+ display: false,
139
+ details: {
140
+ jobs: parts.map(({ job, text, dropped }) => {
141
+ const shown = tailText(text);
142
+ return jobDetails(job, shown.text, shown.truncated || dropped > 0);
143
+ }),
144
+ },
145
+ },
146
+ { triggerTurn: false },
147
+ );
148
+ } catch {
149
+ /* the session may already be gone */
150
+ }
151
+ }
152
+ return stopped;
153
+ };
154
+ const hook: BgShellHook = {
155
+ async stopAll(sessionId?: string) {
156
+ const stopped = await stopAll(sessionId);
157
+ return stopped.map((job) => ({ id: job.id, command: job.command }));
158
+ },
159
+ };
160
+ (globalThis as any)[BGSHELL_HOOK] = hook;
161
+
162
+ pi.on("session_shutdown", async (event: any, ctx: any) => {
163
+ await stopAll();
164
+ const stopped = stoppedAtEnd.filter((job) => job.background || job.status === "killed");
165
+ stoppedAtEnd = [];
166
+ if (stopped.length === 0) return;
167
+ const text = `omnirush: stopped ${plural(stopped.length, "background command")} as the session ended: ${stopped
168
+ .map((job) => `${job.id} (${shortCommand(job.command, 60)})`)
169
+ .join(", ")}`;
170
+ if (!event?.reason || event.reason === "quit") {
171
+ // After the terminal is restored, so the line stays readable.
172
+ process.once("exit", () => {
173
+ try {
174
+ writeSync(2, `${text}\n`);
175
+ } catch {
176
+ /* stderr may be closed */
177
+ }
178
+ });
179
+ } else if (ctx?.ui?.notify) {
180
+ ctx.ui.notify(text, "warning");
181
+ }
182
+ });
183
+
184
+ // --- the bash tool ---------------------------------------------------------------
185
+
186
+ const base = createBashToolDefinition(process.cwd());
187
+ const BashParams = Type.Object({
188
+ command: Type.String({ description: "Shell command to execute" }),
189
+ timeout: Type.Optional(Type.Number({
190
+ description: `Seconds to wait for the command in the foreground (default ${fgDefault}, at most ${MAX_FOREGROUND_SECONDS}). A command still running then is NOT killed: it moves to the background, you get its id and the output so far, and its result is delivered to you when it finishes`,
191
+ })),
192
+ run_in_background: Type.Optional(Type.Boolean({
193
+ description: "true: start the command in the background and return at once with its id (dev servers, watchers, long builds or test suites while you do other work). Its result is delivered to you as a message when it finishes",
194
+ })),
195
+ });
196
+
197
+ pi.registerTool({
198
+ ...base,
199
+ name: "bash",
200
+ label: "bash",
201
+ description: [
202
+ "Execute a bash command in the current working directory. Returns stdout and stderr (output is truncated to the last lines; full output is saved to a temp file).",
203
+ `A command that runs longer than its foreground window (timeout, default ${fgDefault} s) is not killed: it continues in the background and its result arrives later as a message.`,
204
+ "run_in_background: true starts it in the background right away.",
205
+ "bash_output reads a background command's new output and status, bash_wait waits for it, bash_kill stops it.",
206
+ ].join(" "),
207
+ promptSnippet: "Execute bash commands (ls, grep, find, etc.); long-running commands continue in the background",
208
+ promptGuidelines: [
209
+ ...(Array.isArray(base.promptGuidelines) ? base.promptGuidelines : []),
210
+ "Long-running commands (dev servers, watchers, long builds or test suites) you do not need to wait for: bash with run_in_background: true, then keep working; their result arrives as a message when they finish, so do not poll bash_output in a loop.",
211
+ "Stop background commands you no longer need (servers, watchers) with bash_kill before you finish.",
212
+ ],
213
+ parameters: BashParams,
214
+
215
+ async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
216
+ const session = hub.sessionOf(ctx) || "session";
217
+ const explicit = params?.run_in_background === true;
218
+ const windowMs = explicit ? 0 : foregroundSeconds(params?.timeout, fgDefault) * 1000;
219
+ let job: ShellJob | null = null;
220
+
221
+ const operations = {
222
+ exec: (command: string, cwd: string, execOptions: any) =>
223
+ new Promise<{ exitCode: number | null }>((resolve, reject) => {
224
+ const current = manager.start({ session, command, cwd, env: execOptions?.env, toolCallId, onData: execOptions?.onData });
225
+ job = current;
226
+ let timer: ReturnType<typeof setTimeout> | null = null;
227
+ const onAbort = () => { void manager.kill([current]); };
228
+ const cleanup = () => {
229
+ if (timer) clearTimeout(timer);
230
+ execOptions?.signal?.removeEventListener?.("abort", onAbort);
231
+ };
232
+ const moveToBackground = () => {
233
+ if (current.status !== "running") return;
234
+ cleanup();
235
+ manager.toBackground(current, explicit);
236
+ reject(new MovedToBackground());
237
+ };
238
+ if (execOptions?.signal) {
239
+ if (execOptions.signal.aborted) onAbort();
240
+ else execOptions.signal.addEventListener("abort", onAbort, { once: true });
241
+ }
242
+ if (windowMs <= 0) moveToBackground();
243
+ else timer = setTimeout(moveToBackground, windowMs);
244
+ void current.done.then(() => {
245
+ if (current.background) return;
246
+ cleanup();
247
+ // Its output reaches the model through this tool result.
248
+ current.delivered = true;
249
+ if (current.status === "killed") reject(new Error("aborted"));
250
+ else if (current.status === "failed") reject(new Error(current.error ?? "command failed"));
251
+ else resolve({ exitCode: current.exitCode });
252
+ });
253
+ }),
254
+ };
255
+
256
+ const definition = createBashToolDefinition(ctx?.cwd ? String(ctx.cwd) : process.cwd(), { operations: operations as any });
257
+ try {
258
+ return await definition.execute(toolCallId, { command: params?.command }, signal, onUpdate, ctx);
259
+ } catch (error) {
260
+ const moved = job as ShellJob | null;
261
+ if (!(error instanceof MovedToBackground) || !moved) throw error;
262
+ const hint = "Its result arrives as a message in this conversation when it finishes (no need to poll). bash_output reads its new output, bash_wait waits for it, bash_kill stops it.";
263
+ let text: string;
264
+ if (explicit) {
265
+ text = `Started in the background as ${moved.id}: ${shortCommand(moved.command, 200)}\n${hint}`;
266
+ } else {
267
+ const soFar = tailText(moved.output.since(0).text);
268
+ text = [
269
+ `Still running after ${Math.round(windowMs / 1000)} s — moved to the background as ${moved.id} (not killed).`,
270
+ hint,
271
+ "",
272
+ "Output so far:",
273
+ `${soFar.truncated ? "[earlier output omitted]\n" : ""}${soFar.text.trim() ? soFar.text.replace(/\s+$/, "") : "(no output yet)"}`,
274
+ ].join("\n");
275
+ }
276
+ return {
277
+ content: [{ type: "text", text }],
278
+ details: { background: { id: moved.id, command: moved.command, explicit, started_at: moved.startedAt } },
279
+ };
280
+ }
281
+ },
282
+ });
283
+
284
+ // --- companion tools -----------------------------------------------------------
285
+
286
+ const IdsParam = Type.Optional(Type.Array(Type.String(), {
287
+ description: 'Background command ids (e.g. "sh2"); omit (or "all") for every background command of this session',
288
+ }));
289
+
290
+ const readResult = (jobs: ShellJob[], unknown: string[]) => {
291
+ const parts = collect(jobs);
292
+ const sections = parts.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text);
293
+ if (unknown.length) sections.push(`Unknown ids: ${unknown.join(", ")}`);
294
+ return {
295
+ content: [{ type: "text", text: sections.join("\n\n---\n\n") || "No background commands in this session." }],
296
+ details: {
297
+ jobs: parts.map(({ job, text, dropped }) => {
298
+ const shown = tailText(text);
299
+ return jobDetails(job, shown.text, shown.truncated || dropped > 0);
300
+ }),
301
+ },
302
+ };
303
+ };
304
+
305
+ pi.registerTool({
306
+ name: "bash_output",
307
+ label: "bash output",
308
+ description: "A background command's output since you last read it, and its status (running, or its exit code). Without an id: the status of every background command of this session. Results are delivered automatically when a command finishes — use this to check progress, not to poll in a loop.",
309
+ promptSnippet: "bash_output: new output and status of a background command",
310
+ parameters: Type.Object({
311
+ id: Type.Optional(Type.String({ description: 'Background command id (e.g. "sh2"); omit for the status of all' })),
312
+ }),
313
+ async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
314
+ const session = hub.sessionOf(ctx) || "session";
315
+ const id = typeof params?.id === "string" ? params.id.trim() : "";
316
+ if (!id || id === "all") {
317
+ const jobs = manager.resolve(session, []).jobs;
318
+ return {
319
+ content: [{ type: "text", text: renderJobStatus(jobs) }],
320
+ details: { jobs: jobs.map((job) => ({ id: job.id, command: job.command, status: job.status, exit_code: job.exitCode })) },
321
+ };
322
+ }
323
+ const { jobs, unknown } = manager.resolve(session, [id]);
324
+ return readResult(jobs, unknown);
325
+ },
326
+ });
327
+
328
+ pi.registerTool({
329
+ name: "bash_wait",
330
+ label: "bash wait",
331
+ description: "Wait until background commands finish (default: every running one) and return their new output and exit codes. timeout_seconds bounds the WAIT only — the commands keep running past it.",
332
+ promptSnippet: "bash_wait: wait for background commands to finish",
333
+ parameters: Type.Object({
334
+ ids: IdsParam,
335
+ timeout_seconds: Type.Optional(Type.Number({ description: `Leave out (or 0) to wait until they finish (at most ${MAX_WAIT_SECONDS} s per call); otherwise stop WAITING after this many seconds` })),
336
+ }),
337
+ async execute(_toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
338
+ const session = hub.sessionOf(ctx) || "session";
339
+ const explicit = Array.isArray(params?.ids) && params.ids.length > 0 && !params.ids.includes("all");
340
+ const resolved = manager.resolve(session, params?.ids);
341
+ const jobs = explicit ? resolved.jobs : resolved.jobs.filter((job) => job.status === "running" || !job.delivered);
342
+ if (jobs.length === 0 && resolved.unknown.length === 0) {
343
+ return { content: [{ type: "text", text: "No background commands to wait for." }], details: { jobs: [] } };
344
+ }
345
+ const seconds = typeof params?.timeout_seconds === "number" && params.timeout_seconds > 0
346
+ ? Math.min(params.timeout_seconds, MAX_WAIT_SECONDS)
347
+ : MAX_WAIT_SECONDS;
348
+ onUpdate?.({ content: [{ type: "text", text: `bash_wait: waiting for ${jobs.filter((job) => job.status === "running").map((job) => job.id).join(", ") || "nothing"}` }], details: {} });
349
+ await manager.wait(jobs, { timeoutMs: seconds * 1000, ...(signal ? { signal } : {}) });
350
+ return readResult(jobs, resolved.unknown);
351
+ },
352
+ });
353
+
354
+ pi.registerTool({
355
+ name: "bash_kill",
356
+ label: "bash kill",
357
+ description: 'Stop background commands (the whole process tree) by id, or ["all"]; returns their last output.',
358
+ promptSnippet: "bash_kill: stop background commands",
359
+ parameters: Type.Object({
360
+ ids: Type.Array(Type.String(), { description: 'Background command ids, or ["all"]', minItems: 1 }),
361
+ }),
362
+ async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
363
+ const session = hub.sessionOf(ctx) || "session";
364
+ const { jobs, unknown } = manager.resolve(session, params?.ids);
365
+ const live = jobs.filter((job) => job.status === "running");
366
+ await manager.kill(live);
367
+ if (live.length === 0) {
368
+ const done = jobs.map((job) => `${job.id} already ${jobOutcome(job)}`);
369
+ return {
370
+ content: [{ type: "text", text: `Nothing to stop${done.length ? ` (${done.join(", ")})` : ""}${unknown.length ? `; unknown ids: ${unknown.join(", ")}` : ""}.` }],
371
+ details: { jobs: [] },
372
+ };
373
+ }
374
+ return readResult(live, unknown);
375
+ },
376
+ });
377
+
378
+ pi.registerCommand("jobs", {
379
+ description: "Background shell commands of this session; /jobs kill <id|all> stops them",
380
+ handler: async (args: string, ctx: any) => {
381
+ const session = hub.sessionOf(ctx) || "session";
382
+ const words = String(args ?? "").trim().split(/\s+/).filter(Boolean);
383
+ if (words[0] === "kill") {
384
+ const { jobs } = manager.resolve(session, words.slice(1).length ? words.slice(1) : ["all"]);
385
+ const stopped = await manager.kill(jobs.filter((job) => job.status === "running"), { deliver: true });
386
+ ctx?.ui?.notify?.(stopped.length ? `Stopped ${stopped.map((job) => job.id).join(", ")}.` : "No running background commands.");
387
+ return;
388
+ }
389
+ ctx?.ui?.notify?.(renderJobStatus(manager.resolve(session, []).jobs));
390
+ },
391
+ });
392
+ }
@@ -58,6 +58,7 @@ import {
58
58
  } from "./pi-engine";
59
59
  import { sharedRefresher } from "./refresh";
60
60
  import { AGENTS_HOOK, type AgentsHook } from "./agents-lib";
61
+ import { BGSHELL_HOOK, type BgShellHook } from "./bgshell-lib";
61
62
 
62
63
  /** info.agent of the root session's messages (a sub-agent's is its role). */
63
64
  export const ROOT_AGENT = "omnirush-cli";
@@ -83,11 +84,12 @@ const SWITCH_BUDGET_MS = 30_000;
83
84
  */
84
85
  const PRINT_EXIT_GRACE_MS = 2_000;
85
86
 
86
- /** The pi agent config dir: session files live under <dir>/sessions. */
87
+ /** The agent data dir: session files live under <dir>/sessions (the launcher always sets it). */
87
88
  function piAgentDir(): string {
88
- const override = process.env.PI_CODING_AGENT_DIR || process.env.OMNIRUSH_CODING_AGENT_DIR || "";
89
+ const override = process.env.OMNIRUSH_CODING_AGENT_DIR || process.env.PI_CODING_AGENT_DIR || "";
89
90
  if (override.trim()) return path.resolve(override.trim());
90
- return path.join(os.homedir(), ".pi", "agent");
91
+ const stateDir = (process.env.OMNIRUSH_DIR || "").trim();
92
+ return path.join(stateDir ? path.resolve(stateDir) : path.join(os.homedir(), ".omnirush"), "agent");
91
93
  }
92
94
 
93
95
  /** pi session ids are UUIDs; the collector refuses anything outside its id alphabet. */
@@ -124,26 +126,68 @@ function removeLegacyJournals(stateDir: string): void {
124
126
  }
125
127
  }
126
128
 
127
- /** Sub-agents a session started: its spawn_agents results (session id, role, task). */
128
- export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string; role: string | null; task: string | null }> {
129
- const children: Array<{ id: string; role: string | null; task: string | null }> = [];
129
+ /** A sub-agent that ran on the main model instead of the picked one (spawn_agents' model_fallback). */
130
+ export type ChildModelFallback = { requested: string; used: string; effort: string | null; reason: string; kind: string; note: string };
131
+
132
+ /** Tools and messages that report sub-agent results after spawn_agents named them. */
133
+ const CHILD_RESULT_TOOLS = new Set(["agents_wait", "agents_result", "agents_cancel"]);
134
+ const CHILD_RESULT_MESSAGE = "omnirush-agents-result";
135
+
136
+ function modelFallbackOf(result: any): ChildModelFallback | null {
137
+ const fallback = result && typeof result.model_fallback === "object" ? result.model_fallback : null;
138
+ if (!fallback || typeof fallback.requested !== "string" || typeof fallback.used !== "string") return null;
139
+ return {
140
+ requested: fallback.requested,
141
+ used: fallback.used,
142
+ effort: typeof fallback.effort === "string" ? fallback.effort : null,
143
+ reason: typeof fallback.reason === "string" ? fallback.reason : "refused",
144
+ kind: typeof fallback.kind === "string" ? fallback.kind : "selection",
145
+ note: typeof fallback.note === "string" ? fallback.note : `ran on ${fallback.used}`,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Sub-agents a session started: its spawn_agents results (session id, role,
151
+ * task), with the model fallback a later result reported (a background
152
+ * child's delivery, agents_wait / agents_result) when there is one.
153
+ */
154
+ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> {
155
+ const children: Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> = [];
130
156
  for (const entry of entries) {
131
157
  const message = entry.type === "message" ? entry.message : null;
132
- if (!message || message.role !== "toolResult" || message.toolName !== "spawn_agents") continue;
133
- const results = message.details && Array.isArray(message.details.results) ? message.details.results : [];
158
+ const spawned = Boolean(message && message.role === "toolResult" && message.toolName === "spawn_agents");
159
+ const details = spawned
160
+ ? message!.details
161
+ : message && message.role === "toolResult" && CHILD_RESULT_TOOLS.has(message.toolName)
162
+ ? message.details
163
+ : entry.type === "custom_message" && (entry as any).customType === CHILD_RESULT_MESSAGE
164
+ ? (entry as any).details
165
+ : null;
166
+ const results = details && Array.isArray(details.results) ? details.results : [];
134
167
  for (const result of results) {
135
168
  const id = result && typeof result.session_id === "string" ? result.session_id : "";
136
- if (!sanitizeSessionId(id) || children.some((child) => child.id === id)) continue;
169
+ if (!sanitizeSessionId(id)) continue;
170
+ const known = children.find((child) => child.id === id);
171
+ const modelFallback = modelFallbackOf(result);
172
+ if (known) {
173
+ if (modelFallback) known.modelFallback = modelFallback;
174
+ continue;
175
+ }
176
+ if (!spawned) continue;
137
177
  children.push({
138
178
  id,
139
179
  role: typeof result.role === "string" ? result.role : null,
140
180
  task: typeof result.task === "string" ? result.task : null,
181
+ ...(modelFallback ? { modelFallback } : {}),
141
182
  });
142
183
  }
143
184
  }
144
185
  return children;
145
186
  }
146
187
 
188
+ /** Fallbacks already recorded as trace events (root session : child session). */
189
+ const recordedFallbacks = new Set<string>();
190
+
147
191
  /** The session file of `sessionId`: `<ts>_<id>.jsonl` in one of the session dirs. */
148
192
  async function findSessionFile(sessionId: string, dirs: readonly string[]): Promise<string | null> {
149
193
  for (const dir of dirs) {
@@ -233,13 +277,26 @@ export async function captureChildSessions(input: {
233
277
  if (history.omitted > 0) {
234
278
  input.collector.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
235
279
  }
280
+ const fallbackKey = `${input.rootSessionId}:${child.id}`;
281
+ if (child.modelFallback && !recordedFallbacks.has(fallbackKey)) {
282
+ // As the desktop: the sub-agent ran on the main model, and why.
283
+ recordedFallbacks.add(fallbackKey);
284
+ input.collector.recordTrace(input.rootSessionId, "subagent.model_fallback", {
285
+ child_session_id: child.id,
286
+ requested: child.modelFallback.requested,
287
+ used: child.modelFallback.used,
288
+ effort: child.modelFallback.effort,
289
+ reason: child.modelFallback.reason,
290
+ kind: child.modelFallback.kind,
291
+ });
292
+ }
236
293
  if (history.delta.length > 0 || !input.known.has(child.id)) {
237
294
  input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
238
295
  input.collector.recordChildSession(input.rootSessionId, {
239
296
  childSessionId: child.id,
240
297
  parentSessionId: input.parentSessionId,
241
298
  depth: input.depth,
242
- title: child.task,
299
+ title: child.task && child.modelFallback ? `${child.task} (${child.modelFallback.note})` : child.task,
243
300
  agent: turnModelFromMessages(messages)?.agent ?? agent,
244
301
  messages: history.delta,
245
302
  lastMessageId: typeof messages.at(-1)?.info.id === "string" ? (messages.at(-1)!.info.id as string) : null,
@@ -319,7 +376,7 @@ export default function (pi: any) {
319
376
  // value is a bare release number); client_version / client_platform are
320
377
  // the backend's optional envelope metadata.
321
378
  appVersion: version ? `omnirush-cli/${version}` : "omnirush-cli",
322
- ...(engineVersion ? { engineVersion: `pi/${engineVersion}` } : {}),
379
+ ...(engineVersion ? { engineVersion: `omnirush-core/${engineVersion}` } : {}),
323
380
  envelopeMetadata: {
324
381
  ...(version ? { client_version: version } : {}),
325
382
  client_platform: `${process.platform}/${process.arch}`,
@@ -527,6 +584,10 @@ export default function (pi: any) {
527
584
  const hook = (globalThis as any)[AGENTS_HOOK] as AgentsHook | undefined;
528
585
  const followed = session;
529
586
  const interrupted = followed?.started && hook ? await hook.interruptAll(followed.id).catch(() => []) : [];
587
+ // Background shell commands still running are stopped too; their output
588
+ // is added to the session as a message, so the last capture keeps it.
589
+ const shell = (globalThis as any)[BGSHELL_HOOK] as BgShellHook | undefined;
590
+ if (followed?.started && shell) await shell.stopAll(followed.id).catch(() => []);
530
591
  await settleRunning("interrupted");
531
592
  const state = session;
532
593
  if (!state?.started) return;
@@ -21,6 +21,7 @@ import { readSessionLedger, SESSION_LEDGER_FILE } from "./capture/workspace-coll
21
21
  import { deviceMe, omniDir, resolveOrigin, sessionEndedMessage } from "./auth";
22
22
  import { sharedRefresher } from "./refresh";
23
23
  import { formatCollectorLine, formatStatusLines } from "./status-lib";
24
+ import { formatModeStatus } from "./yolo-lib";
24
25
  import {
25
26
  formatDeviceLines,
26
27
  formatGrantLines,
@@ -153,6 +154,7 @@ export default function (pi: ExtensionAPI) {
153
154
  collector: { sessionId, record },
154
155
  origin: resolveOrigin(process.env),
155
156
  version: CLI_VERSION,
157
+ yolo: formatModeStatus(),
156
158
  });
157
159
  notify(ctx, ["Omnirush status:", ...statusLines.map((line) => ` ${line}`)].join("\n"));
158
160
  },