omnirush 0.8.6 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/assets/CHANGELOG.md +120 -0
  2. package/assets/extensions/omnirush/agents-lib.ts +501 -63
  3. package/assets/extensions/omnirush/agents.ts +140 -115
  4. package/assets/extensions/omnirush/bgshell-lib.ts +432 -0
  5. package/assets/extensions/omnirush/bgshell.ts +392 -0
  6. package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
  7. package/assets/extensions/omnirush/collector.ts +275 -43
  8. package/assets/extensions/omnirush/commands.ts +2 -0
  9. package/assets/extensions/omnirush/deliveries.ts +145 -0
  10. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  11. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  12. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  13. package/assets/extensions/omnirush/guard.ts +340 -0
  14. package/assets/extensions/omnirush/index.ts +32 -6
  15. package/assets/extensions/omnirush/mcp.ts +103 -8
  16. package/assets/extensions/omnirush/memory-lib.ts +516 -0
  17. package/assets/extensions/omnirush/pi-engine.ts +115 -3
  18. package/assets/extensions/omnirush/plan-lib.ts +38 -9
  19. package/assets/extensions/omnirush/plan.ts +85 -30
  20. package/assets/extensions/omnirush/sota.ts +52 -4
  21. package/assets/extensions/omnirush/status-lib.ts +3 -0
  22. package/assets/extensions/omnirush/subagent-marker.ts +21 -0
  23. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  24. package/assets/extensions/omnirush/subagents.ts +305 -0
  25. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  26. package/assets/extensions/omnirush/swarm.ts +95 -0
  27. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  28. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  29. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  30. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  31. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  32. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  33. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  34. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  35. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  36. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  37. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  38. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  39. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  40. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  41. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  42. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  43. package/assets/extensions/omnirush/voice.ts +838 -0
  44. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  45. package/assets/extensions/omnirush/yolo.ts +85 -0
  46. package/package.json +7 -3
  47. package/scripts/brand-engine.js +526 -0
  48. package/scripts/build-all-packages.py +29 -1
  49. package/scripts/smoke-packages.py +33 -1
  50. package/src/bin.js +232 -33
  51. package/src/compat.js +272 -0
  52. package/src/lib.js +64 -0
  53. package/src/sessions.js +222 -0
  54. package/scripts/patch-pi-branding.js +0 -251
@@ -32,10 +32,20 @@
32
32
  // session reaches the parent's trace as session.child events, like the
33
33
  // desktop's task sub-agents.
34
34
  //
35
+ // A turn's prompt is only what the user submitted. A sub-agent process
36
+ // marks its own session (an `omnirush-subagent` entry naming its parent),
37
+ // so when that session is later opened as a main session (`--continue`,
38
+ // `--resume`, `--session`) its first user message, the task the parent
39
+ // agent wrote, is recorded as synthetic; for sub-agents that wrote no
40
+ // marker, the parent session file naming it in a spawn result confirms
41
+ // it. A prompt an extension sends (pi's input event, source "extension")
42
+ // is synthetic too.
43
+ //
35
44
  // Silent no-op without device credentials: collection only runs for
36
45
  // signed-in omnirush identities.
37
46
 
38
47
  import { createHash } from "node:crypto";
48
+ import markSubagentSession from "./subagent-marker";
39
49
  import { createReadStream, readdirSync, readFileSync, rmSync } from "node:fs";
40
50
  import { readdir, stat } from "node:fs/promises";
41
51
  import os from "node:os";
@@ -51,13 +61,17 @@ import {
51
61
  engineMessagesFromEntries,
52
62
  MAX_TURN_MESSAGE_BYTES,
53
63
  messageParts,
64
+ subagentTask,
65
+ SUBAGENT_ENTRY_TYPE,
54
66
  turnHistory,
55
67
  turnModelFromMessages,
68
+ userText,
56
69
  type EngineMessage,
57
70
  type PiEntry,
58
71
  } from "./pi-engine";
59
72
  import { sharedRefresher } from "./refresh";
60
- import { AGENTS_HOOK, type AgentsHook } from "./agents-lib";
73
+ import { AGENTS_HOOK, SUBAGENT_SESSIONS_DIR, type AgentsHook } from "./agents-lib";
74
+ import { BGSHELL_HOOK, type BgShellHook } from "./bgshell-lib";
61
75
 
62
76
  /** info.agent of the root session's messages (a sub-agent's is its role). */
63
77
  export const ROOT_AGENT = "omnirush-cli";
@@ -83,11 +97,12 @@ const SWITCH_BUDGET_MS = 30_000;
83
97
  */
84
98
  const PRINT_EXIT_GRACE_MS = 2_000;
85
99
 
86
- /** The pi agent config dir: session files live under <dir>/sessions. */
100
+ /** The agent data dir: session files live under <dir>/sessions (the launcher always sets it). */
87
101
  function piAgentDir(): string {
88
- const override = process.env.PI_CODING_AGENT_DIR || process.env.OMNIRUSH_CODING_AGENT_DIR || "";
102
+ const override = process.env.OMNIRUSH_CODING_AGENT_DIR || process.env.PI_CODING_AGENT_DIR || "";
89
103
  if (override.trim()) return path.resolve(override.trim());
90
- return path.join(os.homedir(), ".pi", "agent");
104
+ const stateDir = (process.env.OMNIRUSH_DIR || "").trim();
105
+ return path.join(stateDir ? path.resolve(stateDir) : path.join(os.homedir(), ".omnirush"), "agent");
91
106
  }
92
107
 
93
108
  /** pi session ids are UUIDs; the collector refuses anything outside its id alphabet. */
@@ -124,26 +139,68 @@ function removeLegacyJournals(stateDir: string): void {
124
139
  }
125
140
  }
126
141
 
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 }> = [];
142
+ /** A sub-agent that ran on the main model instead of the picked one (spawn_agents' model_fallback). */
143
+ export type ChildModelFallback = { requested: string; used: string; effort: string | null; reason: string; kind: string; note: string };
144
+
145
+ /** Tools and messages that report sub-agent results after spawn_agents named them. */
146
+ const CHILD_RESULT_TOOLS = new Set(["agents_wait", "agents_result", "agents_cancel"]);
147
+ const CHILD_RESULT_MESSAGE = "omnirush-agents-result";
148
+
149
+ function modelFallbackOf(result: any): ChildModelFallback | null {
150
+ const fallback = result && typeof result.model_fallback === "object" ? result.model_fallback : null;
151
+ if (!fallback || typeof fallback.requested !== "string" || typeof fallback.used !== "string") return null;
152
+ return {
153
+ requested: fallback.requested,
154
+ used: fallback.used,
155
+ effort: typeof fallback.effort === "string" ? fallback.effort : null,
156
+ reason: typeof fallback.reason === "string" ? fallback.reason : "refused",
157
+ kind: typeof fallback.kind === "string" ? fallback.kind : "selection",
158
+ note: typeof fallback.note === "string" ? fallback.note : `ran on ${fallback.used}`,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Sub-agents a session started: its spawn_agents results (session id, role,
164
+ * task), with the model fallback a later result reported (a background
165
+ * child's delivery, agents_wait / agents_result) when there is one.
166
+ */
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 }> = [];
130
169
  for (const entry of entries) {
131
170
  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 : [];
171
+ const spawned = Boolean(message && message.role === "toolResult" && message.toolName === "spawn_agents");
172
+ const details = spawned
173
+ ? message!.details
174
+ : message && message.role === "toolResult" && CHILD_RESULT_TOOLS.has(message.toolName)
175
+ ? message.details
176
+ : entry.type === "custom_message" && (entry as any).customType === CHILD_RESULT_MESSAGE
177
+ ? (entry as any).details
178
+ : null;
179
+ const results = details && Array.isArray(details.results) ? details.results : [];
134
180
  for (const result of results) {
135
181
  const id = result && typeof result.session_id === "string" ? result.session_id : "";
136
- if (!sanitizeSessionId(id) || children.some((child) => child.id === id)) continue;
182
+ if (!sanitizeSessionId(id)) continue;
183
+ const known = children.find((child) => child.id === id);
184
+ const modelFallback = modelFallbackOf(result);
185
+ if (known) {
186
+ if (modelFallback) known.modelFallback = modelFallback;
187
+ continue;
188
+ }
189
+ if (!spawned) continue;
137
190
  children.push({
138
191
  id,
139
192
  role: typeof result.role === "string" ? result.role : null,
140
193
  task: typeof result.task === "string" ? result.task : null,
194
+ ...(modelFallback ? { modelFallback } : {}),
141
195
  });
142
196
  }
143
197
  }
144
198
  return children;
145
199
  }
146
200
 
201
+ /** Fallbacks already recorded as trace events (root session : child session). */
202
+ const recordedFallbacks = new Set<string>();
203
+
147
204
  /** The session file of `sessionId`: `<ts>_<id>.jsonl` in one of the session dirs. */
148
205
  async function findSessionFile(sessionId: string, dirs: readonly string[]): Promise<string | null> {
149
206
  for (const dir of dirs) {
@@ -159,6 +216,71 @@ async function findSessionFile(sessionId: string, dirs: readonly string[]): Prom
159
216
  return null;
160
217
  }
161
218
 
219
+ /** Session files read at most when looking for a sub-agent's parent, and for how long. */
220
+ const MAX_PARENT_SCAN_FILES = 64;
221
+ const PARENT_SCAN_BUDGET_MS = 3_000;
222
+
223
+ /** Whether `file` contains `needle` (streamed in chunks, never held whole). */
224
+ async function fileContains(file: string, needle: string, deadline: number): Promise<boolean> {
225
+ const input = createReadStream(file, { encoding: "utf8", highWaterMark: 256 * 1024 });
226
+ let tail = "";
227
+ try {
228
+ for await (const chunk of input) {
229
+ const text = tail + (chunk as string);
230
+ if (text.includes(needle)) return true;
231
+ if (Date.now() > deadline) return false;
232
+ tail = text.slice(-needle.length);
233
+ }
234
+ } catch {
235
+ /* unreadable: not the parent */
236
+ } finally {
237
+ input.destroy();
238
+ }
239
+ return false;
240
+ }
241
+
242
+ /**
243
+ * The session that started sub-agent `childId`: the session file in `dir`
244
+ * whose spawn_agents (or agents_*) results name it, among the files
245
+ * written since the sub-agent started (`since`, epoch ms; the newest
246
+ * first, at most MAX_PARENT_SCAN_FILES, within PARENT_SCAN_BUDGET_MS).
247
+ */
248
+ export async function findSpawningSession(childId: string, dir: string, since: number): Promise<string | null> {
249
+ const deadline = Date.now() + PARENT_SCAN_BUDGET_MS;
250
+ const found = await findSpawningSessionIn(childId, dir, since, deadline);
251
+ if (found) return found;
252
+ // A sub-agent session moved into <session dir>/subagents/ (src/sessions.js,
253
+ // or written there by a newer parent): its parent sits one level up.
254
+ if (path.basename(dir) === SUBAGENT_SESSIONS_DIR) {
255
+ return findSpawningSessionIn(childId, path.dirname(dir), since, deadline);
256
+ }
257
+ return null;
258
+ }
259
+
260
+ async function findSpawningSessionIn(childId: string, dir: string, since: number, deadline: number): Promise<string | null> {
261
+ let names: string[];
262
+ try {
263
+ names = await readdir(dir);
264
+ } catch {
265
+ return null;
266
+ }
267
+ const candidates: Array<{ file: string; id: string; mtime: number }> = [];
268
+ for (const name of names) {
269
+ const match = /_([A-Za-z0-9._:-]{8,128})\.jsonl$/.exec(name);
270
+ if (!match || match[1] === childId) continue;
271
+ const file = path.join(dir, name);
272
+ const stats = await stat(file).catch(() => null);
273
+ if (stats && stats.mtimeMs >= since) candidates.push({ file, id: match[1]!, mtime: stats.mtimeMs });
274
+ }
275
+ candidates.sort((a, b) => b.mtime - a.mtime);
276
+ const needle = `"session_id":${JSON.stringify(childId)}`;
277
+ for (const candidate of candidates.slice(0, MAX_PARENT_SCAN_FILES)) {
278
+ if (Date.now() > deadline) break;
279
+ if (await fileContains(candidate.file, needle, deadline)) return candidate.id;
280
+ }
281
+ return null;
282
+ }
283
+
162
284
  /** Parse a session JSONL file without buffering the raw transcript or its line array. */
163
285
  export async function parseSessionFileStream(file: string): Promise<{ header: PiEntry | null; entries: PiEntry[] }> {
164
286
  let header: PiEntry | null = null;
@@ -209,7 +331,8 @@ export async function captureChildSessions(input: {
209
331
  visited: Set<string>;
210
332
  /** Unchanged session files are not read again in this process. */
211
333
  seenFiles: Map<string, string>;
212
- beforeRecord: (bytes: number) => void;
334
+ /** Before each event is recorded (may wait: the caller flushes and drains the upload queue). */
335
+ beforeRecord: (bytes: number) => void | Promise<void>;
213
336
  }): Promise<void> {
214
337
  if (input.depth > MAX_CHILD_DEPTH) return;
215
338
  for (const child of spawnedChildren(input.parentEntries).slice(0, MAX_CHILDREN)) {
@@ -233,13 +356,26 @@ export async function captureChildSessions(input: {
233
356
  if (history.omitted > 0) {
234
357
  input.collector.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
235
358
  }
359
+ const fallbackKey = `${input.rootSessionId}:${child.id}`;
360
+ if (child.modelFallback && !recordedFallbacks.has(fallbackKey)) {
361
+ // As the desktop: the sub-agent ran on the main model, and why.
362
+ recordedFallbacks.add(fallbackKey);
363
+ input.collector.recordTrace(input.rootSessionId, "subagent.model_fallback", {
364
+ child_session_id: child.id,
365
+ requested: child.modelFallback.requested,
366
+ used: child.modelFallback.used,
367
+ effort: child.modelFallback.effort,
368
+ reason: child.modelFallback.reason,
369
+ kind: child.modelFallback.kind,
370
+ });
371
+ }
236
372
  if (history.delta.length > 0 || !input.known.has(child.id)) {
237
- input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
373
+ await input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
238
374
  input.collector.recordChildSession(input.rootSessionId, {
239
375
  childSessionId: child.id,
240
376
  parentSessionId: input.parentSessionId,
241
377
  depth: input.depth,
242
- title: child.task,
378
+ title: child.task && child.modelFallback ? `${child.task} (${child.modelFallback.note})` : child.task,
243
379
  agent: turnModelFromMessages(messages)?.agent ?? agent,
244
380
  messages: history.delta,
245
381
  lastMessageId: typeof messages.at(-1)?.info.id === "string" ? (messages.at(-1)!.info.id as string) : null,
@@ -252,7 +388,13 @@ export async function captureChildSessions(input: {
252
388
 
253
389
  export default function (pi: any) {
254
390
  // A sub-agent: the parent session captures it (session.child events).
255
- if ((process.env.OMNIRUSH_PARENT_SESSION || "").trim()) return;
391
+ const parentSession = (process.env.OMNIRUSH_PARENT_SESSION || "").trim();
392
+ if (parentSession) {
393
+ // Its session says so (subagent-marker.ts; index.ts loads it for
394
+ // sub-agent processes, which do not load the collector).
395
+ markSubagentSession(pi, parentSession);
396
+ return;
397
+ }
256
398
  const origin = resolveOrigin(process.env);
257
399
  const gatewayUrl = process.env.OMNIRUSH_GATEWAY_URL || gatewayUrlForOrigin(origin);
258
400
  // The shared auth file is the source of truth (another process may have
@@ -319,7 +461,7 @@ export default function (pi: any) {
319
461
  // value is a bare release number); client_version / client_platform are
320
462
  // the backend's optional envelope metadata.
321
463
  appVersion: version ? `omnirush-cli/${version}` : "omnirush-cli",
322
- ...(engineVersion ? { engineVersion: `pi/${engineVersion}` } : {}),
464
+ ...(engineVersion ? { engineVersion: `omnirush-core/${engineVersion}` } : {}),
323
465
  envelopeMetadata: {
324
466
  ...(version ? { client_version: version } : {}),
325
467
  client_platform: `${process.platform}/${process.arch}`,
@@ -372,21 +514,34 @@ export default function (pi: any) {
372
514
  pendingPrompts: Array<{ at: number; model: { providerID: string; modelID: string } | null; variant: string | null }>;
373
515
  /** User entry id -> what its prompt was sent with (pi-engine promptSettings). */
374
516
  promptSettings: Map<string, { model: { providerID: string; modelID: string } | null; variant: string | null }>;
517
+ /** Prompts an extension sent (pi's input event, source "extension"), until their user entry is found. */
518
+ extensionInputs: Array<{ at: number; text: string }>;
519
+ /** User entries the user did not submit -> their parts' metadata (pi-engine injectedPrompts). */
520
+ injectedPrompts: Map<string, Record<string, unknown>>;
521
+ /** Whether the session is a sub-agent's (its first prompt the parent agent's task), once looked up. */
522
+ origin: Promise<void> | null;
375
523
  } | null = null;
376
524
  let settling: Promise<void> = Promise.resolve();
377
525
 
526
+ // Where sub-agent session files are looked up: the session's own
527
+ // subagents/ folder first (where spawn_agents puts them, and where the
528
+ // launcher moves older releases' children), then the session dirs
529
+ // themselves (children of releases up to 0.9.0 not moved yet), then
530
+ // every project's.
378
531
  const sessionDirs = (ctx: any): string[] => {
379
532
  const dirs: string[] = [];
533
+ const add = (dir: unknown) => {
534
+ if (typeof dir !== "string" || !dir) return;
535
+ for (const candidate of [path.join(dir, SUBAGENT_SESSIONS_DIR), dir]) {
536
+ if (!dirs.includes(candidate)) dirs.push(candidate);
537
+ }
538
+ };
380
539
  const file = ctx?.sessionManager?.getSessionFile?.();
381
- if (typeof file === "string" && file) dirs.push(path.dirname(file));
382
- const sessionDir = ctx?.sessionManager?.getSessionDir?.();
383
- if (typeof sessionDir === "string" && sessionDir && !dirs.includes(sessionDir)) dirs.push(sessionDir);
540
+ if (typeof file === "string" && file) add(path.dirname(file));
541
+ add(ctx?.sessionManager?.getSessionDir?.());
384
542
  try {
385
543
  const root = path.join(agentDir, "sessions");
386
- for (const name of readdirSync(root)) {
387
- const dir = path.join(root, name);
388
- if (!dirs.includes(dir)) dirs.push(dir);
389
- }
544
+ for (const name of readdirSync(root)) add(path.join(root, name));
390
545
  } catch {
391
546
  /* no sessions yet */
392
547
  }
@@ -406,7 +561,23 @@ export default function (pi: any) {
406
561
  // never wrote a user entry (handled by an extension) is let go.
407
562
  else if (pending.at > Date.now() - 60_000) state.pendingPrompts.push(pending);
408
563
  }
409
- return engineMessagesFromEntries(entries, { sessionId: state.id, agent: ROOT_AGENT, cwd: state.root, toolStarts: state.toolStarts, promptSettings: state.promptSettings });
564
+ // An extension's prompt is the first user entry with its text written at or after it went out.
565
+ for (const input of state.extensionInputs.splice(0)) {
566
+ const entry = entries.find((candidate: any) =>
567
+ candidate?.type === "message" && candidate.message?.role === "user" && typeof candidate.id === "string"
568
+ && !state.injectedPrompts.has(candidate.id) && userText(candidate.message) === input.text
569
+ && Number(candidate.message.timestamp ?? Date.parse(candidate.timestamp)) >= input.at - 1_000);
570
+ if (entry) state.injectedPrompts.set(entry.id, { source: "extension" });
571
+ else if (input.at > Date.now() - 60_000) state.extensionInputs.push(input);
572
+ }
573
+ return engineMessagesFromEntries(entries, {
574
+ sessionId: state.id,
575
+ agent: ROOT_AGENT,
576
+ cwd: state.root,
577
+ toolStarts: state.toolStarts,
578
+ promptSettings: state.promptSettings,
579
+ injectedPrompts: state.injectedPrompts,
580
+ });
410
581
  };
411
582
 
412
583
  /**
@@ -419,7 +590,21 @@ export default function (pi: any) {
419
590
  if (!id) return null;
420
591
  if (session?.id !== id) {
421
592
  const root = ctx?.cwd || process.cwd();
422
- session = { id, root, ctx, started: false, running: false, lastMessageId: undefined, toolStarts: new Map(), seenFiles: new Map(), pendingPrompts: [], promptSettings: new Map() };
593
+ session = {
594
+ id,
595
+ root,
596
+ ctx,
597
+ started: false,
598
+ running: false,
599
+ lastMessageId: undefined,
600
+ toolStarts: new Map(),
601
+ seenFiles: new Map(),
602
+ pendingPrompts: [],
603
+ promptSettings: new Map(),
604
+ extensionInputs: [],
605
+ injectedPrompts: new Map(),
606
+ origin: null,
607
+ };
423
608
  }
424
609
  session.ctx = ctx;
425
610
  // Idempotent: a session already started in this process keeps its segment.
@@ -435,7 +620,7 @@ export default function (pi: any) {
435
620
  * sub-agents keep running past the turn that started them: each later
436
621
  * settle (and the session's end) records what they did since.
437
622
  */
438
- const captureChildren = async (state: NonNullable<typeof session>, room: (bytes: number) => void): Promise<void> => {
623
+ const captureChildren = async (state: NonNullable<typeof session>, room: (bytes: number) => void | Promise<void>): Promise<void> => {
439
624
  const { id } = state;
440
625
  try {
441
626
  await captureChildSessions({
@@ -456,6 +641,26 @@ export default function (pi: any) {
456
641
  }
457
642
  };
458
643
 
644
+ /**
645
+ * Room for the next sub-agent event: once the recorded messages would pass
646
+ * one flush (MAX_TURN_MESSAGE_BYTES), they flush, and the next child is
647
+ * read only after that upload went out. A turn with many sub-agents (20
648
+ * children, MiBs each) used to read and queue all of them at once, holding
649
+ * every child's messages, their trace documents and envelopes in memory
650
+ * together.
651
+ */
652
+ const childRoom = (id: string) => {
653
+ let pending = 0;
654
+ return async (bytes: number): Promise<void> => {
655
+ if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
656
+ collector.flushTrace(id);
657
+ pending = 0;
658
+ await collector.drained(id);
659
+ }
660
+ pending += bytes;
661
+ };
662
+ };
663
+
459
664
  /**
460
665
  * Everything a settled run records, in the desktop observer's order:
461
666
  * messages, sub-agents, the idle event, the snapshot, the trace and the
@@ -483,22 +688,17 @@ export default function (pi: any) {
483
688
  for (const [index, part] of parts.slice(0, -1).entries()) {
484
689
  collector.recordTrace(id, "turn.messages", { messages: part.messages, part: index + 1, parts: parts.length });
485
690
  collector.flushTrace(id);
691
+ // One part waits in memory at a time (a resumed session's backlog is up to 32 MiB).
692
+ await collector.drained(id);
486
693
  }
487
694
  } catch (error) {
488
695
  collector.recordTrace(id, "session.messages_failed", { error: error instanceof Error ? error.message : "unknown" });
489
696
  }
490
697
  const newest = parts.at(-1)!;
491
698
  // Sub-agent messages flush on their own before they would crowd the turn's out.
492
- let pending = 0;
493
- const room = (bytes: number) => {
494
- if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
495
- collector.flushTrace(id);
496
- pending = 0;
497
- }
498
- pending += bytes;
499
- };
699
+ const room = childRoom(id);
500
700
  await captureChildren(state, room);
501
- room(newest.bytes);
701
+ await room(newest.bytes);
502
702
  collector.recordTrace(id, "session.idle", { status });
503
703
  // The turn snapshot runs first so the artifacts it discovers are part of
504
704
  // the trace flushed right behind it.
@@ -527,6 +727,10 @@ export default function (pi: any) {
527
727
  const hook = (globalThis as any)[AGENTS_HOOK] as AgentsHook | undefined;
528
728
  const followed = session;
529
729
  const interrupted = followed?.started && hook ? await hook.interruptAll(followed.id).catch(() => []) : [];
730
+ // Background shell commands still running are stopped too; their output
731
+ // is added to the session as a message, so the last capture keeps it.
732
+ const shell = (globalThis as any)[BGSHELL_HOOK] as BgShellHook | undefined;
733
+ if (followed?.started && shell) await shell.stopAll(followed.id).catch(() => []);
530
734
  await settleRunning("interrupted");
531
735
  const state = session;
532
736
  if (!state?.started) return;
@@ -537,14 +741,7 @@ export default function (pi: any) {
537
741
  if (typeof lastId !== "string" || lastId === state.lastMessageId) {
538
742
  // No new message of its own: what its sub-agents did since the last settle.
539
743
  settling = settling.then(async () => {
540
- let pending = 0;
541
- await captureChildren(state, (bytes) => {
542
- if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
543
- collector.flushTrace(state.id);
544
- pending = 0;
545
- }
546
- pending += bytes;
547
- });
744
+ await captureChildren(state, childRoom(state.id));
548
745
  collector.flushTrace(state.id);
549
746
  }).catch(() => undefined);
550
747
  await settling;
@@ -588,11 +785,45 @@ export default function (pi: any) {
588
785
  clearTimeout(budget);
589
786
  };
590
787
 
788
+ /**
789
+ * A session opened as a main session that is a sub-agent's own (its file
790
+ * sits with the main sessions, so `--continue` or the resume picker can
791
+ * reopen it): its first prompt is the task the parent agent wrote, not
792
+ * something the user typed. Looked up once, before the first prompt of
793
+ * this process is written (every user entry then on the branch came
794
+ * before this process).
795
+ */
796
+ const lookUpOrigin = (state: NonNullable<typeof session>): Promise<void> =>
797
+ (state.origin ??= (async () => {
798
+ const entries: PiEntry[] = state.ctx?.sessionManager?.getBranch?.() ?? [];
799
+ const task = subagentTask(entries);
800
+ if (!task) return;
801
+ let parent = task.parentSessionId;
802
+ if (!task.marked) {
803
+ // Sub-agents that wrote no marker: the parent session names it.
804
+ const file = state.ctx?.sessionManager?.getSessionFile?.();
805
+ const started = Date.parse(String(state.ctx?.sessionManager?.getHeader?.()?.timestamp ?? entries[0]?.timestamp ?? ""));
806
+ parent = typeof file === "string" && file
807
+ ? await findSpawningSession(state.id, path.dirname(file), Number.isFinite(started) ? started - 10 * 60_000 : 0).catch(() => null)
808
+ : null;
809
+ if (!parent) return;
810
+ }
811
+ state.injectedPrompts.set(task.entryId, { subagentTask: true, ...(parent ? { parentSessionID: parent } : {}) });
812
+ })().catch(() => undefined));
813
+
591
814
  pi.on("session_start", (_event: any, ctx: any) => {
592
815
  follow(ctx, false);
593
816
  });
594
817
 
595
- pi.on("before_agent_start", (event: any, ctx: any) => {
818
+ // What the user submits comes as input; an extension's prompt
819
+ // (pi.sendUserMessage) is marked so it never reads as the user's.
820
+ pi.on("input", (event: any, ctx: any) => {
821
+ if (event?.source !== "extension" || typeof event?.text !== "string") return;
822
+ const state = follow(ctx, false);
823
+ state?.extensionInputs.push({ at: Date.now(), text: event.text });
824
+ });
825
+
826
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
596
827
  const state = follow(ctx, true);
597
828
  if (!state) return;
598
829
  state.running = true;
@@ -649,6 +880,7 @@ export default function (pi: any) {
649
880
  },
650
881
  });
651
882
  archive.turnFollowed(state.id);
883
+ await lookUpOrigin(state);
652
884
  });
653
885
 
654
886
  // A run that no prompt started (a background sub-agent's results
@@ -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
  },
@@ -0,0 +1,145 @@
1
+ // deliveries — brings results of background work back into the conversation.
2
+ //
3
+ // Background sub-agents (agents.ts) and background shell commands
4
+ // (bgshell.ts) finish while the main agent works on, waits for the user, or
5
+ // is about to end a one-shot run. Each registers as a source here; one hub
6
+ // per extension instance delivers what they have:
7
+ //
8
+ // - the agent is busy: the result is steered into the running turn (the
9
+ // model sees it before its next step);
10
+ // - the agent is idle: the result starts a new turn;
11
+ // - one-shot runs (print/json mode) end when the agent settles, so the hub
12
+ // holds the run open while a source still has work out, delivers each
13
+ // result as it comes (a new run nested in this settle, whose own settle
14
+ // resumes this one), and gives each source one last word (`beforeEnd`)
15
+ // before the run is allowed to end.
16
+
17
+ export interface DeliveryItem {
18
+ customType: string;
19
+ content: string;
20
+ details?: unknown;
21
+ }
22
+
23
+ export interface DeliverySource {
24
+ /** Work of this session whose result the run should still wait for (running, or finished and unread). */
25
+ hasPending(session: string): boolean;
26
+ /** Finished results ready now (each becomes one message); marked delivered. */
27
+ take(session: string): DeliveryItem[];
28
+ /** Resolves at the next change (something finished). */
29
+ changed(): Promise<void>;
30
+ /** One-shot runs with nothing pending: a last message before the run ends, or null. */
31
+ beforeEnd?(session: string): DeliveryItem | null;
32
+ }
33
+
34
+ export class DeliveryHub {
35
+ private readonly pi: any;
36
+ private readonly sources: DeliverySource[] = [];
37
+ /** The session this runtime is on (deliveries go to it). */
38
+ currentSession = "";
39
+ cwd = process.cwd();
40
+ /** An agent run is under way (one-shot runs deliver through the settle loop otherwise). */
41
+ private busy = false;
42
+ private oneShot = false;
43
+ private resumeOuter: (() => void) | null = null;
44
+
45
+ constructor(pi: any) {
46
+ this.pi = pi;
47
+ pi.on("session_start", (_event: any, ctx: any) => {
48
+ this.sessionOf(ctx);
49
+ });
50
+ pi.on("agent_start", (_event: any, ctx: any) => {
51
+ this.sessionOf(ctx);
52
+ this.busy = true;
53
+ });
54
+ pi.on("agent_settled", (_event: any, ctx: any) => this.settled(ctx));
55
+ }
56
+
57
+ addSource(source: DeliverySource): void {
58
+ this.sources.push(source);
59
+ }
60
+
61
+ /** A source has something to deliver (called after its own bookkeeping). */
62
+ notify(): void {
63
+ queueMicrotask(() => this.deliver());
64
+ }
65
+
66
+ /** The session of a tool or event context (and what the hub learns from it). */
67
+ sessionOf(ctx: any): string {
68
+ const id = String(ctx?.sessionManager?.getSessionId?.() ?? "");
69
+ if (id) this.currentSession = id;
70
+ if (ctx?.cwd) this.cwd = String(ctx.cwd);
71
+ if (ctx?.mode === "print" || ctx?.mode === "json") this.oneShot = true;
72
+ return id;
73
+ }
74
+
75
+ get isOneShot(): boolean {
76
+ return this.oneShot;
77
+ }
78
+
79
+ private send(item: DeliveryItem): void {
80
+ this.pi.sendMessage(
81
+ { customType: item.customType, content: item.content, display: true, ...(item.details !== undefined ? { details: item.details } : {}) },
82
+ // Busy: into the running turn at its next step. Idle: a new turn.
83
+ { triggerTurn: true, deliverAs: "steer" },
84
+ );
85
+ }
86
+
87
+ private deliver(): void {
88
+ if (!this.currentSession) return;
89
+ // One-shot and between runs: the agent_settled loop delivers.
90
+ if (this.oneShot && !this.busy) return;
91
+ for (const source of this.sources) {
92
+ for (const item of source.take(this.currentSession)) this.send(item);
93
+ }
94
+ }
95
+
96
+ private async settled(ctx: any): Promise<void> {
97
+ this.busy = false;
98
+ const session = this.sessionOf(ctx);
99
+ if (!this.oneShot) {
100
+ // Anything that finished right as the run ended.
101
+ this.deliver();
102
+ return;
103
+ }
104
+ const outer = this.resumeOuter;
105
+ this.resumeOuter = null;
106
+ const idle = () => typeof ctx?.isIdle !== "function" || ctx.isIdle();
107
+ try {
108
+ for (;;) {
109
+ if (!idle()) {
110
+ // A run started (a delivery): its settle resumes this one.
111
+ await new Promise<void>((resolve) => { this.resumeOuter = resolve; });
112
+ break;
113
+ }
114
+ if (!session) break;
115
+ const items = this.sources.flatMap((source) => source.take(session));
116
+ if (items.length === 0 && this.sources.some((source) => source.hasPending(session))) {
117
+ await Promise.race(this.sources.map((source) => source.changed()));
118
+ continue;
119
+ }
120
+ if (items.length === 0) {
121
+ const last = this.sources.map((source) => source.beforeEnd?.(session) ?? null).find((item) => item !== null);
122
+ if (!last) break;
123
+ items.push(last);
124
+ }
125
+ this.busy = true;
126
+ for (const item of items) this.send(item);
127
+ if (idle()) this.busy = false;
128
+ }
129
+ } finally {
130
+ outer?.();
131
+ }
132
+ }
133
+ }
134
+
135
+ const hubs = new WeakMap<object, DeliveryHub>();
136
+
137
+ /** The hub of this extension instance (created, with its handlers, on first use). */
138
+ export function deliveryHub(pi: any): DeliveryHub {
139
+ let hub = hubs.get(pi);
140
+ if (!hub) {
141
+ hub = new DeliveryHub(pi);
142
+ hubs.set(pi, hub);
143
+ }
144
+ return hub;
145
+ }
@@ -0,0 +1,2 @@
1
+ Vendored from omnirush-gui apps/server/src/git-command-policy.ts at 51ee01f (origin/main).
2
+ Keep it byte-identical to upstream; CLI-specific logic lives in ../guard-lib.ts.