omnirush 0.7.0 → 0.8.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 (31) hide show
  1. package/assets/collect-once.ts +72 -141
  2. package/assets/extensions/omnirush/agents-lib.ts +23 -13
  3. package/assets/extensions/omnirush/agents.ts +19 -24
  4. package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
  5. package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
  6. package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
  7. package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
  8. package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
  9. package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
  10. package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
  11. package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
  12. package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
  13. package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
  14. package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
  15. package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
  16. package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
  17. package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
  18. package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
  19. package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
  20. package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
  21. package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
  22. package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
  23. package/assets/extensions/omnirush/collector.ts +539 -171
  24. package/assets/extensions/omnirush/commands.ts +1 -1
  25. package/assets/extensions/omnirush/pi-engine.ts +655 -0
  26. package/assets/extensions/omnirush/plan.ts +6 -7
  27. package/package.json +5 -2
  28. package/scripts/build-capture-vendor.js +55 -0
  29. package/src/bin.js +30 -5
  30. package/assets/extensions/omnirush/collector-lib.ts +0 -2433
  31. package/assets/extensions/omnirush/trace-format.ts +0 -311
@@ -2,174 +2,105 @@
2
2
  // trigger; the same snapshots also flow automatically during agent
3
3
  // sessions via the omnirush pi extension).
4
4
  //
5
- // Builds the standard collect envelope (schema_version 1, snapshot_type
6
- // "start") for a project directory and POSTs each zstd-compressed part
7
- // to {origin}/collect with the device access token. Requires the
8
- // bundled bun (zstd) or Node >= 22.15.
5
+ // Runs the session collector (the desktop app's, see
6
+ // extensions/omnirush/capture/) for one manual session: the start
7
+ // snapshot of the folder (files[] within the snapshot cap, the full
8
+ // manifest, the git block; gitignored and denied paths left out, content
9
+ // scrubbed) and the session's end snapshot, each one zstd envelope POSTed
10
+ // to {origin}/collect with the device access token. A retryable failure
11
+ // stays in the collector's spool and is sent by the next CLI run.
9
12
  //
10
- // Memory (issue #6): files are read and packed one part at a time and
11
- // each part is discarded right after its upload — nothing workspace-
12
- // sized is ever held in full. The pre-flight summary walks sizes only
13
- // (lstat), never content. Deterministic order: the walker yields paths
14
- // sorted, each path's entries (manifest + slices) grouped.
15
- //
16
- // Usage: omnirush collect [--dir PATH] [--yes] [--dry-run]
13
+ // Usage: omnirush collect [--dir PATH] [--dry-run]
17
14
 
18
- import { randomBytes } from "node:crypto";
15
+ import { createHash, randomBytes } from "node:crypto";
19
16
  import path from "node:path";
20
- import readline from "node:readline";
17
+ import { zstdDecompressSync } from "node:zlib";
21
18
 
22
- import { resolveOrigin, gatewayUrlForOrigin } from "./extensions/omnirush/auth";
23
- import {
24
- iterateCollectFiles,
25
- iterateEnvelopeParts,
26
- MAX_PART_COMPRESSED_BYTES,
27
- MAX_PART_COMPRESSED_HARD,
28
- MAX_SESSION_BYTES,
29
- } from "./extensions/omnirush/collector-lib";
19
+ import { gatewayUrlForOrigin, omniDir, resolveOrigin } from "./extensions/omnirush/auth";
20
+ import { WorkspaceCollector } from "./extensions/omnirush/capture/workspace-collector";
21
+ import { sharedRefresher } from "./extensions/omnirush/refresh";
30
22
 
31
- function parseArgs(argv: string[]): { dir: string; yes: boolean; dryRun: boolean } {
23
+ function parseArgs(argv: string[]): { dir: string; dryRun: boolean } {
32
24
  let dir = process.cwd();
33
- let yes = false;
34
25
  let dryRun = false;
35
26
  for (let i = 0; i < argv.length; i++) {
36
27
  const arg = argv[i];
37
28
  if (arg === "--dir" && argv[i + 1]) {
38
29
  dir = path.resolve(argv[++i]);
39
- } else if (arg === "--yes") {
40
- yes = true;
41
30
  } else if (arg === "--dry-run") {
42
31
  dryRun = true;
32
+ } else if (arg === "--yes") {
33
+ // Accepted for compatibility: the upload never asks.
43
34
  } else if (arg === "-h" || arg === "--help") {
44
- console.log("usage: omnirush collect [--dir PATH] [--yes] [--dry-run]");
35
+ console.log("usage: omnirush collect [--dir PATH] [--dry-run]");
45
36
  process.exit(0);
46
37
  }
47
38
  }
48
- return { dir, yes, dryRun };
49
- }
50
-
51
- async function confirm(question: string): Promise<boolean> {
52
- if (!process.stdin.isTTY) return false;
53
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
54
- const answer = await new Promise<string>((resolve) => rl.question(question, resolve));
55
- rl.close();
56
- return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
57
- }
58
-
59
- /** Cheap workspace summary: path count + on-disk bytes (no content). */
60
- async function summarize(dir: string): Promise<{ files: number; bytes: number }> {
61
- const { lstat } = await import("node:fs/promises");
62
- const { listWorkspaceFiles } = await import("./extensions/omnirush/collector-lib");
63
- const paths = await listWorkspaceFiles(dir);
64
- let bytes = 0;
65
- for (const relativePath of paths) {
66
- try {
67
- bytes += (await lstat(path.join(dir, relativePath))).size;
68
- } catch {
69
- /* raced away — the streaming pass re-checks */
70
- }
71
- }
72
- return { files: paths.length, bytes };
39
+ return { dir, dryRun };
73
40
  }
74
41
 
75
42
  async function main() {
76
- const { dir, yes, dryRun } = parseArgs(process.argv.slice(2));
43
+ const { dir, dryRun } = parseArgs(process.argv.slice(2));
77
44
  const sessionId = `manual-${randomBytes(6).toString("hex")}`;
78
- const workspaceId = path.basename(dir) || "workspace";
79
-
80
- const summary = await summarize(dir);
81
- if (summary.files === 0) {
82
- console.error("omnirush collect: nothing to upload (empty or fully filtered workspace)");
83
- process.exit(1);
84
- }
85
- console.log(
86
- `collect: ${summary.files} files (${summary.bytes} bytes on disk, denylist applied) as session ${sessionId}`,
87
- );
88
-
89
- const origin = resolveOrigin(process.env);
90
- const collectUrl = gatewayUrlForOrigin(origin).replace(/\/v1$/, "/collect");
91
- const accessToken = (
92
- process.env.OMNIRUSH_ACCESS_TOKEN ||
93
- process.env.OMNIRUSH_TOKEN ||
94
- ""
95
- ).trim();
96
- if (!accessToken) {
45
+ const workspaceId = `cli_${createHash("sha256").update(path.resolve(dir)).digest("hex").slice(0, 16)}`;
46
+ const accessToken = (process.env.OMNIRUSH_ACCESS_TOKEN || process.env.OMNIRUSH_TOKEN || "").trim();
47
+ if (!accessToken && !dryRun) {
97
48
  console.error("omnirush collect: no access token (run `omnirush login`)");
98
49
  process.exit(1);
99
50
  }
100
-
101
- const state = { id: sessionId, segment: 1, resumed: false };
102
- // Pass 2 streams content: file -> batch -> part -> upload, with only
103
- // one part ever resident. `part` is dropped at each loop iteration.
104
- const parts = iterateEnvelopeParts(
105
- state,
106
- "start",
107
- iterateCollectFiles(dir, MAX_SESSION_BYTES, workspaceId, {
108
- id: sessionId,
109
- segment: 1,
110
- resumed: false,
111
- }),
112
- MAX_PART_COMPRESSED_BYTES,
113
- (message, attributes) => console.error(`omnirush collect: ${message} (${JSON.stringify(attributes ?? {})})`),
114
- );
115
-
116
- let partCount = 0;
117
- let plainBytes = 0;
118
- let compressedBytes = 0;
119
- let uploaded = false;
120
- for await (const part of parts) {
121
- partCount += 1;
122
- plainBytes += part.payload.length;
123
- compressedBytes += part.compressed.length;
124
- if (dryRun) {
125
- console.log(`part ${partCount}: ${part.compressed.length} bytes zstd (discarded — dry run)`);
126
- continue;
127
- }
128
- const response = await fetch(collectUrl, {
129
- method: "POST",
130
- headers: {
131
- Authorization: `Bearer ${accessToken}`,
132
- "Content-Type": "application/zstd",
133
- "X-Omnirush-Session-ID": sessionId,
134
- },
135
- body: part.compressed.buffer.slice(
136
- part.compressed.byteOffset,
137
- part.compressed.byteOffset + part.compressed.byteLength,
138
- ) as ArrayBuffer,
139
- signal: AbortSignal.timeout(120_000),
140
- });
141
- if (!response.ok) {
142
- await response.body?.cancel().catch(() => undefined);
143
- console.error(`upload failed: HTTP ${response.status}`);
144
- process.exit(1);
145
- }
146
- uploaded = true;
147
- const extra = [...response.headers]
148
- .filter(([name]) => name.toLowerCase().startsWith("x-omnirush-"))
149
- .map(([name, value]) => `${name}: ${value}`);
150
- const sequence = JSON.parse(part.payload.toString("utf8")).sequence;
151
- console.log(
152
- `uploaded part sequence=${sequence} (${part.compressed.length} bytes, HTTP ${response.status})` +
153
- `${extra.length ? "\n " + extra.join("\n ") : ""}`,
154
- );
155
- }
156
-
157
- if (partCount === 0) {
158
- console.error("omnirush collect: nothing to upload (empty or fully filtered workspace)");
159
- process.exit(1);
160
- }
161
- console.log(
162
- `envelope: ${partCount} part(s), ${plainBytes} bytes plain, ${compressedBytes} bytes zstd ` +
163
- `(target <= ${MAX_PART_COMPRESSED_BYTES}, rail ${MAX_PART_COMPRESSED_HARD})`,
164
- );
165
- if (dryRun) {
166
- console.log("dry run — no upload performed");
167
- return;
168
- }
169
- if (!uploaded) {
51
+ const version = (process.env.OMNIRUSH_VERSION || "").trim();
52
+ let uploads = 0;
53
+ let current = accessToken;
54
+ const collector = new WorkspaceCollector({
55
+ gatewayUrl: process.env.OMNIRUSH_GATEWAY_URL || gatewayUrlForOrigin(resolveOrigin(process.env)),
56
+ accessToken,
57
+ // A dry run keeps no state (nothing is spooled for a later run).
58
+ ...(dryRun ? {} : { stateDir: omniDir() }),
59
+ refreshAccessToken: async () => {
60
+ const refresher = sharedRefresher();
61
+ const refreshed = await refresher.refresh(current).catch(() => false);
62
+ const next = refresher.auth?.accessToken ?? null;
63
+ if (!refreshed || !next) return null;
64
+ current = next;
65
+ return next;
66
+ },
67
+ appVersion: version ? `omnirush-cli/${version}` : "omnirush-cli",
68
+ envelopeMetadata: {
69
+ ...(version ? { client_version: version } : {}),
70
+ client_platform: `${process.platform}/${process.arch}`,
71
+ },
72
+ watch: false,
73
+ ...(dryRun
74
+ ? {
75
+ upload: async (_session: string, compressed: Uint8Array) => {
76
+ const envelope = JSON.parse(zstdDecompressSync(compressed).toString("utf8"));
77
+ uploads += 1;
78
+ console.log(
79
+ `${envelope.snapshot_type} snapshot: ${envelope.files.length} files, ${envelope.manifest.length} manifest entries, ` +
80
+ `${compressed.byteLength} bytes zstd (discarded — dry run)`,
81
+ );
82
+ return new Response(null, { status: 200 });
83
+ },
84
+ }
85
+ : {}),
86
+ log: (level, message, attributes) => {
87
+ // A dry run reports its envelopes itself (the upload hook above).
88
+ const uploaded = message === "OmniRush collection artifact uploaded" && !dryRun;
89
+ if (uploaded) uploads += 1;
90
+ if (level === "warn" || uploaded) {
91
+ console.error(`omnirush collect: ${message}${attributes ? ` ${JSON.stringify(attributes)}` : ""}`);
92
+ }
93
+ },
94
+ });
95
+ console.log(`collect: ${dir} as session ${sessionId}`);
96
+ collector.startSession(sessionId, workspaceId, dir);
97
+ collector.finishSession(sessionId);
98
+ await collector.stop();
99
+ if (uploads === 0) {
170
100
  console.error("omnirush collect: nothing uploaded");
171
101
  process.exit(1);
172
102
  }
103
+ console.log(dryRun ? "dry run — no upload performed" : `uploaded ${uploads} envelope(s)`);
173
104
  }
174
105
 
175
106
  main().catch((error) => {
@@ -6,14 +6,17 @@
6
6
  // running pi entry — same runtime, same global extension/config dir)
7
7
  // in the parent's workspace, with `--mode json -p` so their output is
8
8
  // a parseable event stream. Each child writes its own session JSONL
9
- // under <agentDir>/sessions/<cwd-slug>/ — the workspace collector
10
- // ships every session file it finds there as a trace artifact linked
11
- // via parent_session_id, so subagent tracing is free.
9
+ // under <agentDir>/sessions/<cwd-slug>/, under a session id the parent
10
+ // picks (`--session-id`) and returns in the result (`session_id`): the
11
+ // parent session's collector reads the child's session file from it and
12
+ // records the child as a sub-agent of the turn (session.child events,
13
+ // grandchildren included), the way the desktop records task sub-agents.
12
14
  //
13
- // Linkage: children get OMNIRUSH_PARENT_SESSION=<parent session id> in
14
- // their environment (the collector stamps parent_session_id from it).
15
+ // Children get OMNIRUSH_PARENT_SESSION=<parent session id> in their
16
+ // environment: a child process uploads nothing itself.
15
17
 
16
18
  import { spawn as nodeSpawn } from "node:child_process";
19
+ import { randomUUID } from "node:crypto";
17
20
  import { existsSync } from "node:fs";
18
21
  import { mkdtemp, writeFile, rm } from "node:fs/promises";
19
22
  import { tmpdir } from "node:os";
@@ -23,12 +26,6 @@ import path from "node:path";
23
26
  export const CHILD_TIMEOUT_MS = 10 * 60_000;
24
27
  /** Grace between SIGTERM and SIGKILL on timeout. */
25
28
  export const CHILD_KILL_GRACE_MS = 5_000;
26
- /** Default number of children running at once. */
27
- export const MAX_CONCURRENT_CHILDREN = 4;
28
- /** Upper bound on children running at once (swarms). */
29
- export const MAX_CONCURRENCY_CEILING = 12;
30
- /** Upper bound on tasks per spawn_agents call. */
31
- export const MAX_TASKS_PER_CALL = 12;
32
29
  /** Per-child output cap in the structured result (bytes, UTF-8). */
33
30
  export const CHILD_OUTPUT_CAP_BYTES = 50 * 1024;
34
31
 
@@ -114,8 +111,14 @@ export function buildChildArgs(
114
111
  task: string,
115
112
  promptFilePath: string | null,
116
113
  model?: string,
114
+ sessionId?: string,
117
115
  ): string[] {
118
116
  const args: string[] = ["--mode", "json", "-p"];
117
+ if (sessionId) {
118
+ // The child's session id, picked by the parent so its session file can
119
+ // be found and captured as this session's sub-agent.
120
+ args.push("--session-id", sessionId);
121
+ }
119
122
  if (promptFilePath) {
120
123
  args.push("--append-system-prompt", promptFilePath);
121
124
  }
@@ -159,6 +162,8 @@ export type ChildStatus = "completed" | "failed" | "timeout" | "cancelled";
159
162
  export interface ChildResult {
160
163
  role: AgentRole;
161
164
  task: string;
165
+ /** The child's pi session id (its session file is `<ts>_<id>.jsonl`). */
166
+ session_id?: string;
162
167
  /** The gateway model the child ran on (cross-model children). */
163
168
  model?: string;
164
169
  status: ChildStatus;
@@ -187,6 +192,7 @@ export function buildChildResult(
187
192
  task: ChildTask,
188
193
  input: ChildResultInput,
189
194
  durationMs: number,
195
+ sessionId?: string,
190
196
  ): ChildResult {
191
197
  const output = input.finalText;
192
198
  let capped = output;
@@ -198,6 +204,7 @@ export function buildChildResult(
198
204
  return {
199
205
  role: task.role,
200
206
  task: task.task,
207
+ ...(sessionId ? { session_id: sessionId } : {}),
201
208
  ...(task.model ? { model: task.model } : {}),
202
209
  status: input.status ?? (input.exitCode === 0 ? "completed" : "failed"),
203
210
  exitCode: input.exitCode,
@@ -266,6 +273,8 @@ export interface SpawnChildOptions {
266
273
  now?: () => number;
267
274
  /** Progress callback: partial output lines as the child runs. */
268
275
  onChildStdout?: (role: AgentRole, chunk: string) => void;
276
+ /** The child's session id (a fresh UUID by default). */
277
+ sessionId?: string;
269
278
  }
270
279
 
271
280
  /**
@@ -282,9 +291,10 @@ export async function runChildAgent(
282
291
  const startedAt = (options.now ?? Date.now)();
283
292
  const timeoutMs = Math.max(1_000, options.timeoutMs ?? CHILD_TIMEOUT_MS);
284
293
  const spawnImpl = options.spawnImpl ?? nodeSpawn;
294
+ const sessionId = options.sessionId ?? randomUUID();
285
295
 
286
296
  return withRolePromptFile(task.role, async (promptFile) => {
287
- const args = buildChildArgs(task.task, promptFile, task.model);
297
+ const args = buildChildArgs(task.task, promptFile, task.model, sessionId);
288
298
  const invocation = childInvocation(args);
289
299
 
290
300
  return await new Promise<ChildResult>((resolvePromise) => {
@@ -311,7 +321,7 @@ export async function runChildAgent(
311
321
  options.signal.removeEventListener("abort", abortHandler);
312
322
  }
313
323
  resolvePromise(
314
- buildChildResult(task, input, (options.now ?? Date.now)() - startedAt),
324
+ buildChildResult(task, input, (options.now ?? Date.now)() - startedAt, sessionId),
315
325
  );
316
326
  };
317
327
 
@@ -10,12 +10,12 @@
10
10
  // - structured results returned to the parent model
11
11
  //
12
12
  // Tracing is free: every child writes its own session JSONL under
13
- // <agentDir>/sessions/<cwd-slug>/ (children run without --no-session),
14
- // and the workspace collector ships every session file it finds as a
15
- // trace artifact linked via parent_session_id. Children also get
16
- // OMNIRUSH_PARENT_SESSION so a collector running inside them knows the
17
- // parent session (linked mode: no second baseline, authoritative
18
- // parent stamping).
13
+ // <agentDir>/sessions/<cwd-slug>/ (children run without --no-session)
14
+ // under a session id the parent picks, and the parent session's
15
+ // collector records each child's conversation as a sub-agent of the
16
+ // turn (session.child events with parent and depth, as the desktop
17
+ // records task sub-agents). Children get OMNIRUSH_PARENT_SESSION, so a
18
+ // child process uploads nothing of its own.
19
19
  //
20
20
  // `/agents` lists this session's subagents (running + finished).
21
21
 
@@ -27,9 +27,6 @@ import {
27
27
  AGENT_ROLES,
28
28
  CHILD_TIMEOUT_MS,
29
29
  mapWithConcurrency,
30
- MAX_CONCURRENT_CHILDREN,
31
- MAX_CONCURRENCY_CEILING,
32
- MAX_TASKS_PER_CALL,
33
30
  renderChildResults,
34
31
  runChildAgent,
35
32
  type ChildResult,
@@ -47,7 +44,7 @@ const SpawnAgentsParams = Type.Object({
47
44
  description: 'Cross-model child: gateway model id for THIS task (e.g. "muse-spark-1.3", "muse-spark-1.2-contributor" for cheap swarm workers, "gpt-6-astra", "gpt-5.6-sol"). Omit to inherit this session\'s model',
48
45
  })),
49
46
  }),
50
- { description: "Tasks to delegate; they run in parallel", minItems: 1, maxItems: MAX_TASKS_PER_CALL },
47
+ { description: "Tasks to delegate; they all run in parallel", minItems: 1 },
51
48
  ),
52
49
  timeout_minutes: Type.Optional(Type.Number({
53
50
  description: `Per-child wall-clock limit in minutes (default ${CHILD_TIMEOUT_MS / 60_000}; a timed-out child is killed and its partial result is kept)`,
@@ -55,9 +52,8 @@ const SpawnAgentsParams = Type.Object({
55
52
  maximum: 60,
56
53
  })),
57
54
  max_parallel: Type.Optional(Type.Number({
58
- description: `Children running at once (default ${MAX_CONCURRENT_CHILDREN}, max ${MAX_CONCURRENCY_CEILING}). Swarm fan-outs on cheap models (muse) can raise this`,
55
+ description: "Optional throttle: run at most this many children at once. Omit to run every task in parallel (no cap)",
59
56
  minimum: 1,
60
- maximum: MAX_CONCURRENCY_CEILING,
61
57
  })),
62
58
  });
63
59
 
@@ -80,7 +76,7 @@ export default function (pi: ExtensionAPI) {
80
76
  "Delegate tasks to parallel subagents with isolated context windows.",
81
77
  "Each child is a headless agent run in this workspace with a role preset:",
82
78
  "code-searcher (read-only exploration), researcher-web (web research via web_search + web_fetch), general-worker (full tools).",
83
- `Children run in parallel (default ${MAX_CONCURRENT_CHILDREN} at once, up to ${MAX_CONCURRENCY_CEILING}; ${MAX_TASKS_PER_CALL} tasks per call) and each gets its own timeout (default ${CHILD_TIMEOUT_MS / 60_000} min).`,
79
+ `Children run in parallel — every task at once unless max_parallel throttles it — and each gets its own timeout (default ${CHILD_TIMEOUT_MS / 60_000} min).`,
84
80
  '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.',
85
81
  "Every task description must be SELF-CONTAINED: the child cannot see this conversation.",
86
82
  "Use for parallelizable work: broad code surveys, independent research questions, independent implementation chunks.",
@@ -109,21 +105,16 @@ export default function (pi: ExtensionAPI) {
109
105
  });
110
106
  }
111
107
  if (tasks.length === 0) throw new Error("no tasks given");
112
- if (tasks.length > MAX_TASKS_PER_CALL) {
113
- throw new Error(`too many tasks (${tasks.length}); the limit is ${MAX_TASKS_PER_CALL} per call`);
114
- }
115
108
  const parentSessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
116
109
  if (!parentSessionId) throw new Error("no parent session id — subagents cannot be traced");
117
110
  const cwd = ctx?.cwd ? String(ctx.cwd) : process.cwd();
118
111
  const timeoutMinutes = typeof params.timeout_minutes === "number" && params.timeout_minutes > 0
119
112
  ? params.timeout_minutes
120
113
  : undefined;
121
- const concurrency = Math.min(
122
- MAX_CONCURRENCY_CEILING,
123
- Math.max(1, typeof params.max_parallel === "number" && params.max_parallel > 0
124
- ? Math.floor(params.max_parallel)
125
- : MAX_CONCURRENT_CHILDREN),
126
- );
114
+ // No cap: every task runs at once unless the caller throttles.
115
+ const concurrency = typeof params.max_parallel === "number" && params.max_parallel >= 1
116
+ ? Math.floor(params.max_parallel)
117
+ : tasks.length;
127
118
 
128
119
  const entries = registry.get(parentSessionId) ?? [];
129
120
  registry.set(parentSessionId, entries);
@@ -139,6 +130,9 @@ export default function (pi: ExtensionAPI) {
139
130
  return entry;
140
131
  });
141
132
 
133
+ // Children settled so far, for progress updates (the result array
134
+ // below only exists once every child settled).
135
+ const settled: ChildResult[] = [];
142
136
  const results = await mapWithConcurrency<ChildTask, ChildResult>(
143
137
  tasks,
144
138
  concurrency,
@@ -152,9 +146,10 @@ export default function (pi: ExtensionAPI) {
152
146
  const mark = marks[index];
153
147
  mark.status = result.status;
154
148
  mark.durationMs = result.durationMs;
149
+ settled.push(result);
155
150
  onUpdate?.({
156
- content: [{ type: "text", text: `spawn_agents: ${results.filter(Boolean).length + 1}/${tasks.length} settled (latest: ${task.role})` }],
157
- details: { results: [...results.filter(Boolean)] },
151
+ content: [{ type: "text", text: `spawn_agents: ${settled.length}/${tasks.length} settled (latest: ${task.role})` }],
152
+ details: { results: [...settled] },
158
153
  });
159
154
  return result;
160
155
  },
@@ -0,0 +1,21 @@
1
+ Ported from omnirush-ai/omnirush-gui @ 2e166a9f9843dccbedc8277addcd94b752e66adb
2
+ (apps/server/src), unchanged except for the CLI seams marked "CLI:" in the code:
3
+
4
+ workspace-collector.ts vendored minimatch import; exported ledger reader and
5
+ .gitignore helpers; `watch: false` (poll on snapshots)
6
+ and `envelopeMetadata` options
7
+ turn-diff.ts unchanged
8
+ collect-upload-budget.ts unchanged
9
+ session-archive/detect.ts, files.ts, lifecycle.ts, pack.ts, seal.ts unchanged
10
+ session-archive/index.ts, policy.ts, upload.ts vendored zod import
11
+ session-archive/manifest.ts, touched.ts gitignored content left out
12
+ (`ignored` counter, runGit exported)
13
+
14
+ CLI files beside them:
15
+ session-archive/ignored.ts git's ignore rules for the archive scans
16
+ server-fetch.ts external egress = the global fetch
17
+ omnirush-swarm.ts the one constant the collector reads
18
+ vendor/ zod and minimatch (npm run build:capture-vendor)
19
+
20
+ Resync: copy the files again, re-apply the seams, then run
21
+ dev/gui-parity-tests.sh <omnirush-gui checkout>.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * How long one collector upload (POST <gateway>/collect) may take.
3
+ *
4
+ * Envelopes range from a few KiB (a change snapshot) to tens of MiB (the
5
+ * start snapshot of a large workspace), so the deadline grows with the body:
6
+ *
7
+ * - `baseMs` covers connecting, TLS and the first bytes;
8
+ * - the body is allowed `bytesPerSecond`, a conservative uplink rate, capped
9
+ * so that this sending part never exceeds `maxSendMs`;
10
+ * - `responseMs` is the wait for the answer once the body is sent: the
11
+ * gateway scrubs a large envelope before it answers.
12
+ *
13
+ * The fetch implementations the uploads go through (undici in Node and
14
+ * Electron, Electron's net.fetch, Bun's fetch) report no upload progress, so
15
+ * the moment the last body byte left is not observable and the response wait
16
+ * cannot run as a timer of its own. The parts therefore form one deadline for
17
+ * the whole request, computed from the envelope's size before it is sent: a
18
+ * body sent faster than the assumed rate leaves the unused sending time to the
19
+ * response wait.
20
+ */
21
+ export type CollectUploadBudget = {
22
+ baseMs: number;
23
+ bytesPerSecond: number;
24
+ maxSendMs: number;
25
+ responseMs: number;
26
+ };
27
+
28
+ export const COLLECT_UPLOAD_BUDGET: CollectUploadBudget = {
29
+ baseMs: 30_000,
30
+ bytesPerSecond: 128 * 1024,
31
+ maxSendMs: 15 * 60_000,
32
+ responseMs: 120_000,
33
+ };
34
+
35
+ /** The whole-request deadline, in milliseconds, for a collect upload of `bytes`. */
36
+ export function collectUploadTimeoutMs(bytes: number, budget: CollectUploadBudget = COLLECT_UPLOAD_BUDGET): number {
37
+ const sendMs = budget.baseMs + Math.ceil((Math.max(0, bytes) * 1_000) / budget.bytesPerSecond);
38
+ return Math.min(budget.maxSendMs, sendMs) + budget.responseMs;
39
+ }
@@ -0,0 +1,6 @@
1
+ // The one constant the ported workspace collector reads from the desktop's
2
+ // swarm module (apps/server/src/omnirush-swarm.ts): the trace event that
3
+ // records a sub-agent answered on the main model instead of the picked one.
4
+ // The CLI never emits it (its sub-agents run in their own process on the
5
+ // model they were given), but the collector keeps the same handling.
6
+ export const SUBAGENT_MODEL_FALLBACK_TRACE = "subagent.model_fallback";
@@ -0,0 +1,8 @@
1
+ // Egress seam of the ported capture modules (desktop: apps/server/src/
2
+ // server-fetch.ts). The desktop routes external egress through the embedding
3
+ // runtime's network stack; the CLI has one runtime and one fetch, so every
4
+ // upload (collect envelopes, archive API calls, presigned S3 part PUTs) goes
5
+ // through the global fetch.
6
+ export function externalFetch(input: string, init?: RequestInit): Promise<Response> {
7
+ return globalThis.fetch(input, init);
8
+ }