omnirush 0.9.1 → 0.10.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.
@@ -39,6 +39,7 @@ import {
39
39
  type SubagentFallback,
40
40
  } from "./subagents-lib";
41
41
  import { childAuthEnv } from "./auth";
42
+ import { sanitizeToolEnvironment } from "./secret-env";
42
43
  import { yoloActive } from "./yolo-lib";
43
44
 
44
45
  /** childAuthEnv, never throwing (a spawn must not fail on the auth file). */
@@ -444,6 +445,8 @@ const ROLE_RE = /^\s*\{\s*"type"\s*:\s*"message_end"\s*,\s*"message"\s*:\s*\{\s*
444
445
  export class ChildEventScanner {
445
446
  finalText = "";
446
447
  turns = 0;
448
+ /** Monotonic count of model/tool events that represent real progress. */
449
+ progressSerial = 0;
447
450
  readonly toolsRunning = new Set<string>();
448
451
  private line = "";
449
452
  /** head: deciding from the first characters; keep: buffering the whole line; skip: dropping it. */
@@ -486,6 +489,7 @@ export class ChildEventScanner {
486
489
  const head = this.line.slice(0, CHILD_EVENT_HEAD_CHARS);
487
490
  const type = EVENT_TYPE_RE.exec(head)?.[1] ?? null;
488
491
  this.lineType = type;
492
+ if (type === "message_update" || type === "message_start" || type === "turn_start") this.progressSerial += 1;
489
493
  if (type === "message_end") {
490
494
  const role = ROLE_RE.exec(head)?.[1] ?? null;
491
495
  this.lineRole = role;
@@ -514,6 +518,7 @@ export class ChildEventScanner {
514
518
  }
515
519
 
516
520
  private tool(type: string, id: string): void {
521
+ this.progressSerial += 1;
517
522
  if (type === "tool_execution_start") this.toolsRunning.add(id);
518
523
  else this.toolsRunning.delete(id);
519
524
  }
@@ -530,7 +535,7 @@ export class ChildEventScanner {
530
535
  if (!trimmed || !trimmed.includes('"type"')) return;
531
536
  // A short line: skip the types nothing is read from without parsing them.
532
537
  const type = mode === "head" ? EVENT_TYPE_RE.exec(trimmed)?.[1] : undefined;
533
- if (type && type !== "message_end" && type !== "tool_execution_start" && type !== "tool_execution_end") return;
538
+ if (type && type !== "message_end" && type !== "message_update" && type !== "message_start" && type !== "turn_start" && type !== "tool_execution_start" && type !== "tool_execution_end") return;
534
539
  let event: any;
535
540
  try {
536
541
  event = JSON.parse(trimmed);
@@ -541,10 +546,15 @@ export class ChildEventScanner {
541
546
  this.tool(event.type, event.toolCallId);
542
547
  return;
543
548
  }
549
+ if (event?.type === "message_update" || event?.type === "message_start" || event?.type === "turn_start") {
550
+ this.progressSerial += 1;
551
+ return;
552
+ }
544
553
  if (event?.type !== "message_end" || !event.message) return;
545
554
  const message = event.message;
546
555
  if (message.role !== "assistant") return;
547
556
  this.turns += 1;
557
+ this.progressSerial += 1;
548
558
  const parts = Array.isArray(message.content) ? message.content : [];
549
559
  const text = parts
550
560
  .filter((part: any) => part?.type === "text" && typeof part.text === "string")
@@ -602,7 +612,7 @@ export interface SpawnChildOptions {
602
612
  now?: () => number;
603
613
  /** Progress callback: partial output lines as the child runs. */
604
614
  onChildStdout?: (role: AgentRole, chunk: string) => void;
605
- /** Every sign of life from the child (any output), with its event types. */
615
+ /** Meaningful model/tool progress from the child, with its event types. */
606
616
  onActivity?: (activity: ChildActivity) => void;
607
617
  /** The child's session id (a fresh UUID by default). */
608
618
  sessionId?: string;
@@ -618,6 +628,7 @@ export interface ChildActivity {
618
628
  turns: number;
619
629
  /** Tool calls running right now. */
620
630
  toolsRunning: number;
631
+ meaningful: true;
621
632
  }
622
633
 
623
634
  /**
@@ -677,6 +688,7 @@ export async function runChildAgent(
677
688
  const child = spawnImpl(invocation.command, invocation.args, {
678
689
  cwd: options.cwd,
679
690
  shell: false,
691
+ detached: process.platform !== "win32",
680
692
  stdio: ["ignore", "pipe", "pipe"],
681
693
  // Shared credentials by location (OMNIRUSH_DIR), never a token
682
694
  // frozen at the parent's launch: the child re-reads auth.json for
@@ -697,16 +709,33 @@ export async function runChildAgent(
697
709
  };
698
710
 
699
711
  const killTree = () => {
700
- try {
701
- child.kill("SIGTERM");
702
- } catch {
703
- /* already gone */
712
+ const signalGroup = (signal: NodeJS.Signals) => {
713
+ if (process.platform !== "win32" && typeof child.pid === "number" && child.pid > 0) {
714
+ try {
715
+ process.kill(-child.pid, signal);
716
+ return true;
717
+ } catch {
718
+ /* fall back to the direct child below */
719
+ }
720
+ }
721
+ if (process.platform === "win32" && typeof child.pid === "number" && child.pid > 0) {
722
+ try {
723
+ const args = ["/PID", String(child.pid), "/T"];
724
+ if (signal === "SIGKILL") args.push("/F");
725
+ nodeSpawn("taskkill", args, { windowsHide: true, stdio: "ignore" });
726
+ return true;
727
+ } catch {
728
+ /* fall back to the direct child below */
729
+ }
730
+ }
731
+ return false;
732
+ };
733
+ if (!signalGroup("SIGTERM")) {
734
+ try { child.kill("SIGTERM"); } catch { /* already gone */ }
704
735
  }
705
736
  setTimeout(() => {
706
- try {
707
- child.kill("SIGKILL");
708
- } catch {
709
- /* already gone */
737
+ if (!signalGroup("SIGKILL")) {
738
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
710
739
  }
711
740
  }, Math.max(50, options.killGraceMs ?? CHILD_KILL_GRACE_MS)).unref?.();
712
741
  };
@@ -726,10 +755,11 @@ export async function runChildAgent(
726
755
  watchdog = setTimeout(() => kill("stalled"), window);
727
756
  watchdog.unref?.();
728
757
  };
729
- const alive = () => {
758
+ const alive = (meaningful: boolean) => {
759
+ if (!meaningful) return;
730
760
  lastActivity = now();
731
761
  armWatchdog();
732
- options.onActivity?.({ at: lastActivity, turns: events.turns, toolsRunning: toolsRunning.size });
762
+ options.onActivity?.({ at: lastActivity, turns: events.turns, toolsRunning: toolsRunning.size, meaningful: true });
733
763
  };
734
764
  armWatchdog();
735
765
 
@@ -749,8 +779,9 @@ export async function runChildAgent(
749
779
  child.stdout?.setEncoding?.("utf8");
750
780
  child.stdout?.on("data", (chunk: Buffer | string) => {
751
781
  const text = String(chunk);
782
+ const before = events.progressSerial;
752
783
  events.push(text);
753
- alive();
784
+ alive(events.progressSerial !== before);
754
785
  options.onChildStdout?.(task.role, text);
755
786
  });
756
787
  child.stderr?.setEncoding?.("utf8");
@@ -760,10 +791,17 @@ export async function runChildAgent(
760
791
  const lines = (pendingErrLine + text).split("\n");
761
792
  pendingErrLine = lines.pop() ?? "";
762
793
  if (pendingErrLine.length > 64 * 1024) pendingErrLine = pendingErrLine.slice(-64 * 1024);
763
- for (const line of lines) gatewayFallback = parseFallbackMarker(line) ?? gatewayFallback;
794
+ let meaningful = false;
795
+ for (const line of lines) {
796
+ const fallback = parseFallbackMarker(line);
797
+ if (fallback) {
798
+ gatewayFallback = fallback;
799
+ meaningful = true;
800
+ }
801
+ }
764
802
  stderr += text;
765
803
  if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
766
- alive();
804
+ alive(meaningful);
767
805
  });
768
806
  child.on("error", (error: Error) => {
769
807
  finish({
@@ -42,6 +42,7 @@ import {
42
42
  } from "./bgshell-lib";
43
43
  import { deliveryHub } from "./deliveries";
44
44
  import { BACKGROUND_BASH_TYPE } from "./pi-engine";
45
+ import { sanitizeToolEnvironment } from "./secret-env";
45
46
 
46
47
  /** customType of the message that brings finished background commands back (the trace maps it to bash tool parts). */
47
48
  export const BASH_DELIVERY_TYPE = BACKGROUND_BASH_TYPE;
@@ -221,7 +222,14 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
221
222
  const operations = {
222
223
  exec: (command: string, cwd: string, execOptions: any) =>
223
224
  new Promise<{ exitCode: number | null }>((resolve, reject) => {
224
- const current = manager.start({ session, command, cwd, env: execOptions?.env, toolCallId, onData: execOptions?.onData });
225
+ const current = manager.start({
226
+ session,
227
+ command,
228
+ cwd,
229
+ env: sanitizeToolEnvironment(execOptions?.env ?? process.env),
230
+ toolCallId,
231
+ onData: execOptions?.onData,
232
+ });
225
233
  job = current;
226
234
  let timer: ReturnType<typeof setTimeout> | null = null;
227
235
  const onAbort = () => { void manager.kill([current]); };
@@ -79,10 +79,6 @@ const MAX_JOB_AGE_MS = 7 * 24 * 60 * 60_000;
79
79
  const JOB_RETRY_BASE_MS = 60_000;
80
80
  const JOB_RETRY_MAX_MS = 60 * 60_000;
81
81
  const SIGN_OUT_ABORT_TIMEOUT_MS = 5_000;
82
- /** At app start, sessions active (a base, a prompt, a turn) within this long may get a final archive (startFinalCandidates). */
83
- const START_FINAL_WINDOW_MS = 7 * 24 * 60 * 60_000;
84
- /** At most this many sessions are scanned for a final archive at app start. */
85
- const MAX_START_FINALS = 10;
86
82
  /**
87
83
  * How long a folder policy answer (4.4: all folders, touched files) is
88
84
  * reused for folders without `.git`: sessions started meanwhile send no
@@ -515,12 +511,9 @@ export class SessionArchiver {
515
511
  * with a turn captured since its last final archive (the app quit before
516
512
  * that final, or crashed), the most recently active session on each
517
513
  * other folder (the folder may have changed while the app was closed),
518
- * and every touched-files session (its files are its own). Only sessions
519
- * active (a base, a prompt, a turn) within the last 7 days, not stopped
520
- * and not deleted, with a base or (touched files) a touched path: a final
521
- * archive never extends the window, so a chat left alone gets none a week
522
- * after its last turn, however often the app starts. The most recently
523
- * active first, at most 10.
514
+ * and every touched-files session (its files are its own). Sessions are
515
+ * recovered until their final archive is settled; there is no age or count
516
+ * cutoff that can silently lose the final workspace state.
524
517
  */
525
518
  async startFinalCandidates(): Promise<string[]> {
526
519
  try {
@@ -533,7 +526,7 @@ export class SessionArchiver {
533
526
  const parsed = sessionStateSchema.safeParse(await readJsonFile(join(this.dirs.sessions, name)));
534
527
  if (!parsed.success || name !== `${stateKey(parsed.data.session_id)}.json`) continue;
535
528
  const state = parsed.data;
536
- if (state.stopped || state.ended || !(nowMs - Date.parse(state.last_activity_at) <= START_FINAL_WINDOW_MS)) continue;
529
+ if (state.stopped || state.ended || Date.parse(state.last_activity_at) > nowMs) continue;
537
530
  // Without a base, only a touched-files session that touched something may get one.
538
531
  if (state.next_sequence === 0 && !(state.marker === TOUCHED_MARKER && (await this.touched.has(state.session_id)))) continue;
539
532
  sessions.push(state);
@@ -548,7 +541,7 @@ export class SessionArchiver {
548
541
  roots.add(folder);
549
542
  if (state.final_due || newestOnRoot) picked.push(state.session_id);
550
543
  }
551
- return picked.slice(0, MAX_START_FINALS);
544
+ return picked;
552
545
  } catch (error) {
553
546
  this.log("warn", "OmniRush archive could not list the sessions to check at start", { error: errorSummary(error) });
554
547
  return [];
@@ -39,8 +39,8 @@ import {
39
39
  import { stateKey } from "./files.js";
40
40
  import type { ArchiveLog } from "./upload.js";
41
41
 
42
- /** A session keeps at most this many touched paths; later ones are not archived (one log line). */
43
- export const MAX_TOUCHED_PATHS = 100_000;
42
+ /** Kept as a compatibility export; touched paths are no longer silently capped. */
43
+ export const MAX_TOUCHED_PATHS = Number.MAX_SAFE_INTEGER;
44
44
  const MAX_TOUCHED_PATH_CHARS = 4_096;
45
45
  /** A touched symlink is followed to a file inside the root through at most this many links. */
46
46
  const MAX_LINK_HOPS = 8;
@@ -387,11 +387,6 @@ export class TouchedPathStore {
387
387
  }, () => undefined);
388
388
  }
389
389
  if (session.mode === "ignored" || session.known?.has(path) || session.pending.has(path) || touchedPathParts(path) === null) return;
390
- if ((session.known?.size ?? 0) + session.pending.size + session.writing.length >= MAX_TOUCHED_PATHS) {
391
- if (!session.capped) this.options.log("warn", "OmniRush touched-files archive keeps a limited number of paths per session; later ones are not archived", { sessionId, limit: MAX_TOUCHED_PATHS });
392
- session.capped = true;
393
- return;
394
- }
395
390
  session.pending.add(path);
396
391
  if (session.mode === "tracked") this.schedule();
397
392
  }
@@ -92,7 +92,6 @@ const MAX_SESSION_LEDGER_ENTRIES = 512;
92
92
  const SESSION_LEDGER_LOCK_TIMEOUT_MS = 5_000;
93
93
  const SESSION_LEDGER_LOCK_STALE_MS = 60_000;
94
94
  const SESSION_LEDGER_LOCK_RETRY_MS = 25;
95
- const MAX_TOUCHED_PATHS = 128;
96
95
  const MAX_GIT_STATUS_ENTRIES = 500;
97
96
  const MAX_GIT_RECENT_COMMITS = 50;
98
97
  const MAX_GIT_REMOTES = 10;
@@ -4435,7 +4434,7 @@ export class WorkspaceCollector {
4435
4434
  }
4436
4435
 
4437
4436
  private touchedPathsForUpload(state: SessionState): string[] {
4438
- return [...state.touchedPaths].slice(0, MAX_TOUCHED_PATHS).map(collectorPathForUpload);
4437
+ return [...state.touchedPaths].map(collectorPathForUpload);
4439
4438
  }
4440
4439
 
4441
4440
  /**
@@ -16,6 +16,7 @@
16
16
 
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import recorder from "./recorder";
19
+ import recordStreamTiming from "./stream-timing";
19
20
  import sota from "./sota";
20
21
  import mcp from "./mcp";
21
22
  import agents from "./agents";
@@ -37,6 +38,9 @@ export default async function (pi: ExtensionAPI) {
37
38
  const subagent = isSubagentProcess();
38
39
  if (!subagent) (await import("./commands")).default(pi as any);
39
40
  recorder(pi as any);
41
+ // Every process times its own runs (the 2.x trace fields); before the
42
+ // collector, so a settled run's idle entry is written before it is read.
43
+ recordStreamTiming(pi as any);
40
44
  sota(pi as any);
41
45
  if (!subagent) (await import("./collector")).default(pi as any);
42
46
  // Children still mark their own session as a sub-agent's (the collector
@@ -43,6 +43,7 @@ import {
43
43
  type McpServerConfig,
44
44
  } from "./mcp-lib";
45
45
  import { truncateTail } from "@earendil-works/pi-coding-agent";
46
+ import { sanitizeToolEnvironment } from "./secret-env";
46
47
 
47
48
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
48
49
 
@@ -218,7 +219,7 @@ async function connectServer(state: ServerState): Promise<void> {
218
219
  transport = new StdioClientTransport({
219
220
  command: state.config.command!,
220
221
  args: state.config.args ?? [],
221
- ...(state.config.env ? { env: state.config.env } : {}),
222
+ env: sanitizeToolEnvironment({ ...process.env, ...(state.config.env ?? {}) }),
222
223
  stderr: "pipe",
223
224
  });
224
225
  }