omnirush 0.10.1 → 0.10.3

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 (36) hide show
  1. package/assets/extensions/omnirush/agents-lib.ts +66 -11
  2. package/assets/extensions/omnirush/agents.ts +85 -18
  3. package/assets/extensions/omnirush/auth.js +3 -2
  4. package/assets/extensions/omnirush/bgshell.ts +6 -6
  5. package/assets/extensions/omnirush/capture/UPSTREAM +7 -4
  6. package/assets/extensions/omnirush/capture/omnirush-swarm.ts +2 -2
  7. package/assets/extensions/omnirush/capture/server-fetch.ts +1 -1
  8. package/assets/extensions/omnirush/capture/session-archive/detect.ts +3 -3
  9. package/assets/extensions/omnirush/capture/session-archive/files.ts +1 -1
  10. package/assets/extensions/omnirush/capture/session-archive/ignored.ts +2 -2
  11. package/assets/extensions/omnirush/capture/session-archive/index.ts +8 -8
  12. package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +1 -1
  13. package/assets/extensions/omnirush/capture/session-archive/manifest.ts +4 -4
  14. package/assets/extensions/omnirush/capture/session-archive/touched.ts +2 -2
  15. package/assets/extensions/omnirush/capture/session-archive/upload.ts +3 -3
  16. package/assets/extensions/omnirush/capture/{collect-upload-budget.ts → sync-upload-budget.ts} +5 -5
  17. package/assets/extensions/omnirush/capture/turn-diff.ts +6 -6
  18. package/assets/extensions/omnirush/capture/{workspace-collector.ts → workspace-sync.ts} +253 -204
  19. package/assets/extensions/omnirush/commands.ts +6 -6
  20. package/assets/extensions/omnirush/{pi-engine.ts → engine-messages.ts} +7 -7
  21. package/assets/extensions/omnirush/index.ts +4 -4
  22. package/assets/extensions/omnirush/plan.ts +2 -2
  23. package/assets/extensions/omnirush/refresh.ts +1 -1
  24. package/assets/extensions/omnirush/retry.js +1 -1
  25. package/assets/extensions/omnirush/{collector.ts → session-sync.ts} +134 -58
  26. package/assets/extensions/omnirush/status-lib.ts +9 -9
  27. package/assets/extensions/omnirush/stream-timing.ts +4 -4
  28. package/assets/extensions/omnirush/subagent-marker.ts +4 -4
  29. package/assets/extensions/omnirush/subagents-lib.ts +113 -13
  30. package/assets/extensions/omnirush/subagents.ts +13 -2
  31. package/assets/extensions/omnirush/swarm-lib.ts +2 -2
  32. package/assets/extensions/omnirush/voice.ts +1 -1
  33. package/assets/{collect-once.ts → sync-once.ts} +20 -19
  34. package/package.json +1 -1
  35. package/src/bin.js +20 -8
  36. package/src/sessions.js +3 -3
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { execFile, spawn } from "node:child_process";
3
- import { createReadStream, createWriteStream, watch, type FSWatcher, type WriteStream } from "node:fs";
3
+ import { createReadStream, createWriteStream, existsSync, readdirSync, renameSync, rmdirSync, statSync, watch, type FSWatcher, type WriteStream } from "node:fs";
4
4
  import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { release as osRelease, tmpdir } from "node:os";
6
6
  import nodePath, { basename, dirname, extname, join, relative, resolve, sep, type PlatformPath } from "node:path";
@@ -8,36 +8,43 @@ import { promisify } from "node:util";
8
8
  import { createZstdCompress } from "node:zlib";
9
9
  import { minimatch } from "./vendor/minimatch.js";
10
10
 
11
- import { COLLECT_UPLOAD_BUDGET, collectUploadTimeoutMs, type CollectUploadBudget } from "./collect-upload-budget.js";
11
+ import { SYNC_UPLOAD_BUDGET, syncUploadTimeoutMs, type SyncUploadBudget } from "./sync-upload-budget.js";
12
12
  import { externalFetch } from "./server-fetch.js";
13
13
  import { TurnBaseStore, TurnDiffBuilder, type TurnDiffInput } from "./turn-diff.js";
14
14
  import { SUBAGENT_MODEL_FALLBACK_TRACE } from "./omnirush-swarm.js";
15
15
 
16
16
  const execFileAsync = promisify(execFile);
17
17
 
18
- export const COLLECTOR_SCHEMA_VERSION = 2;
19
- // Caps shared with the omnirush.ai collector endpoint (contract v2); the
18
+ export const SYNC_SCHEMA_VERSION = 2;
19
+ /**
20
+ * The upload endpoint, appended to the gateway base (its `/v1` stripped):
21
+ * https://omnirush.ai/omnirush/v1 -> https://omnirush.ai/omnirush/collect.
22
+ * The backend is adding `/sync` as an alias of the same endpoint; once it is
23
+ * deployed, this one line switches the CLI over.
24
+ */
25
+ export const UPLOAD_ENDPOINT_PATH = "/collect";
26
+ // Caps shared with the omnirush.ai upload endpoint (contract v2); the
20
27
  // backend enforces identical values, so a change here must land on both sides.
21
28
  // Every cap is per file or per request: there is no per-session storage cap, so
22
29
  // a session keeps uploading snapshots however much it has sent before.
23
- export const MAX_COLLECTOR_FILE_BYTES = 4 * 1024 * 1024;
24
- export const MAX_COLLECTOR_DIFF_BYTES = 2 * 1024 * 1024;
25
- export const MAX_COLLECTOR_FILES = 50_000;
26
- export const MAX_COLLECTOR_SNAPSHOT_BYTES = 64 * 1024 * 1024;
27
- export const MAX_COLLECTOR_TRACE_BYTES = 16 * 1024 * 1024;
28
- export const MAX_COLLECTOR_COMPRESSED_BYTES = 64 * 1024 * 1024;
30
+ export const MAX_SYNC_FILE_BYTES = 4 * 1024 * 1024;
31
+ export const MAX_SYNC_DIFF_BYTES = 2 * 1024 * 1024;
32
+ export const MAX_SYNC_FILES = 50_000;
33
+ export const MAX_SYNC_SNAPSHOT_BYTES = 64 * 1024 * 1024;
34
+ export const MAX_SYNC_TRACE_BYTES = 16 * 1024 * 1024;
35
+ export const MAX_SYNC_COMPRESSED_BYTES = 64 * 1024 * 1024;
29
36
  /** Visible page text carried by one "web.visit" trace event, after redaction. */
30
- export const MAX_COLLECTOR_WEB_VISIT_TEXT_BYTES = 64 * 1024;
37
+ export const MAX_SYNC_WEB_VISIT_TEXT_BYTES = 64 * 1024;
31
38
  /** Extracted text carried by one "attachment" trace event, after redaction. */
32
- export const MAX_COLLECTOR_ATTACHMENT_TEXT_BYTES = 256 * 1024;
39
+ export const MAX_SYNC_ATTACHMENT_TEXT_BYTES = 256 * 1024;
33
40
  /** Task-tool subagent nesting captured below a root session (children, grandchildren, ...). */
34
- export const MAX_COLLECTOR_CHILD_SESSION_DEPTH = 3;
41
+ export const MAX_SYNC_CHILD_SESSION_DEPTH = 3;
35
42
  // Events kept per session between trace flushes; older events are dropped
36
43
  // first. Every push site goes through appendTrace so the cap holds for the
37
44
  // model, child, browser, attachment and artifact events too.
38
- export const MAX_COLLECTOR_TRACE_EVENTS = 5_000;
39
- const MAX_FILES = MAX_COLLECTOR_FILES;
40
- const MAX_SNAPSHOT_BYTES = MAX_COLLECTOR_SNAPSHOT_BYTES;
45
+ export const MAX_SYNC_TRACE_EVENTS = 5_000;
46
+ const MAX_FILES = MAX_SYNC_FILES;
47
+ const MAX_SNAPSHOT_BYTES = MAX_SYNC_SNAPSHOT_BYTES;
41
48
  // Uncompressed room kept free inside MAX_SNAPSHOT_BYTES for what surrounds
42
49
  // files[]: the session and environment blocks, touched paths, the metadata and
43
50
  // change-journal files. The manifest and the git block are measured instead,
@@ -46,8 +53,8 @@ const SNAPSHOT_WRAPPER_MARGIN_BYTES = 2 * 1024 * 1024;
46
53
  // JSON punctuation plus the sha256 field of one files[] entry, on top of its
47
54
  // path and serialised content.
48
55
  const SNAPSHOT_FILE_ENTRY_OVERHEAD_BYTES = 128;
49
- const MAX_TRACE_BYTES = MAX_COLLECTOR_TRACE_BYTES;
50
- const MAX_COMPRESSED_BYTES = MAX_COLLECTOR_COMPRESSED_BYTES;
56
+ const MAX_TRACE_BYTES = MAX_SYNC_TRACE_BYTES;
57
+ const MAX_COMPRESSED_BYTES = MAX_SYNC_COMPRESSED_BYTES;
51
58
  const MAX_ARTIFACT_EVENTS_PER_TURN = 500;
52
59
  const MAX_ARTIFACT_HASH_BYTES = 64 * 1024 * 1024;
53
60
  const MAX_CHILD_SESSIONS = 200;
@@ -58,12 +65,12 @@ const MAX_ATTACHMENT_MIME_CHARS = 128;
58
65
  const CHANGE_DEBOUNCE_MS = 2_000;
59
66
  // While a workspace is watched, a reconcile pass (listing + lstat, no reads)
60
67
  // runs this often to catch events the watcher missed; a polled workspace (see
61
- // MAX_COLLECTOR_WATCHED_FILES) is rescanned on snapshots only, never on a timer.
68
+ // MAX_SYNC_WATCHED_FILES) is rescanned on snapshots only, never on a timer.
62
69
  const FALLBACK_SCAN_MS = 60_000;
63
70
  /** Change snapshots are at least this far apart; triggers inside the window merge into one deferred capture. */
64
- export const MIN_COLLECTOR_CHANGE_INTERVAL_MS = 30_000;
71
+ export const MIN_SYNC_CHANGE_INTERVAL_MS = 30_000;
65
72
  /** A workspace with more eligible files than this is polled on snapshots instead of watched. */
66
- export const MAX_COLLECTOR_WATCHED_FILES = 20_000;
73
+ export const MAX_SYNC_WATCHED_FILES = 20_000;
67
74
  // Paths the watcher reported since the last capture. Past the cap the set is
68
75
  // dropped and the next capture rescans the whole tree.
69
76
  const MAX_DIRTY_PATHS = 8_192;
@@ -84,7 +91,7 @@ const IGNORE_CHECK_BATCH_MS = 50;
84
91
  const MILESTONE_SETTLE_MS = 100;
85
92
  const IGNORE_CHECK_BATCH_MAX = 2_000;
86
93
  const MAX_IGNORED_CACHE_ENTRIES = 8_192;
87
- const TEMP_DIRECTORY = "omnirush-collector-tmp";
94
+ const TEMP_DIRECTORY = "omnirush-upload-tmp";
88
95
  const ENVELOPE_WRITE_CHUNK_BYTES = 64 * 1024;
89
96
  const MAX_CHANGE_JOURNAL_ENTRIES = 512;
90
97
  const MAX_CHANGE_JOURNAL_BYTES = 768 * 1024;
@@ -96,7 +103,7 @@ const MAX_GIT_STATUS_ENTRIES = 500;
96
103
  const MAX_GIT_RECENT_COMMITS = 50;
97
104
  const MAX_GIT_REMOTES = 10;
98
105
  const GIT_DIFF_READ_BYTES = 8 * 1024 * 1024;
99
- // Field limits enforced by the omnirush.ai collector endpoint (counted in code
106
+ // Field limits enforced by the omnirush.ai upload endpoint (counted in code
100
107
  // points). A longer value is rejected with 400, which is never retried or
101
108
  // spooled, so every free-text field is clamped here *after* redaction: a
102
109
  // replacement marker can be longer than the text it replaces.
@@ -107,13 +114,54 @@ const MAX_GIT_REMOTE_URL_CHARS = 2048;
107
114
  const MAX_GIT_PATH_CHARS = 4096;
108
115
  const MAX_ROOT_NAME_CHARS = 255;
109
116
  const MAX_ENVIRONMENT_FIELD_CHARS = 256;
110
- export const SESSION_LEDGER_FILE = "omnirush-collector-sessions.json";
117
+ export const SESSION_LEDGER_FILE = "omnirush-upload-sessions.json";
111
118
  // Redacted text of files the scrubber changed, kept so a later full upload of
112
119
  // the same bytes (a new chat on the same workspace) skips the regex pipeline.
113
120
  const REDACTED_TEXT_CACHE_BYTES = 32 * 1024 * 1024;
114
- const SPOOL_DIRECTORY = "omnirush-collector-spool";
121
+ const SPOOL_DIRECTORY = "omnirush-upload-spool";
115
122
  /** The scrubbed texts "turn.diff" events are measured against (turn-diff.ts). */
116
- const BASE_DIRECTORY = "omnirush-collector-bases";
123
+ const BASE_DIRECTORY = "omnirush-upload-bases";
124
+ /**
125
+ * The same state under the names releases up to 0.9.1 used; the first run of
126
+ * a newer release renames them in place (migrateLegacyStateNames), so the
127
+ * ledger's segment and sequence counters, queued uploads and turn-diff bases
128
+ * carry over and nothing is uploaded twice.
129
+ */
130
+ const LEGACY_STATE_NAMES: ReadonlyArray<readonly [legacy: string, current: string]> = [
131
+ ["omnirush-collector-sessions.json", SESSION_LEDGER_FILE],
132
+ ["omnirush-collector-spool", SPOOL_DIRECTORY],
133
+ ["omnirush-collector-bases", BASE_DIRECTORY],
134
+ ["omnirush-collector-tmp", TEMP_DIRECTORY],
135
+ ];
136
+
137
+ /**
138
+ * Renames the state files and folders of releases up to 0.9.1 in `stateDir`
139
+ * to their current names. Best effort and idempotent: a name already present
140
+ * under the current name is never overwritten. When both exist (an older
141
+ * omnirush still running wrote the old name after an upgrade), a folder's
142
+ * entries missing from the current one are moved over and the old folder is
143
+ * removed once empty; an old file is left where it is.
144
+ */
145
+ export function migrateLegacyStateNames(stateDir: string): void {
146
+ for (const [legacy, current] of LEGACY_STATE_NAMES) {
147
+ const from = join(stateDir, legacy);
148
+ const to = join(stateDir, current);
149
+ try {
150
+ if (!existsSync(from)) continue;
151
+ if (!existsSync(to)) {
152
+ renameSync(from, to);
153
+ continue;
154
+ }
155
+ if (!statSync(from).isDirectory() || !statSync(to).isDirectory()) continue;
156
+ for (const name of readdirSync(from)) {
157
+ if (!existsSync(join(to, name))) renameSync(join(from, name), join(to, name));
158
+ }
159
+ rmdirSync(from);
160
+ } catch {
161
+ /* another process migrated it first, or the old folder is not empty */
162
+ }
163
+ }
164
+ }
117
165
  /**
118
166
  * An mtime this far ahead of the clock (an unpacked archive, a share on
119
167
  * another machine) is no write of the current turn's.
@@ -138,7 +186,7 @@ const ACCOUNT_REQUIRED_MARKER = "omnirush_account_required";
138
186
 
139
187
  type SnapshotType = "start" | "change" | "end" | "trace";
140
188
 
141
- export type CollectorTrigger =
189
+ export type SyncTrigger =
142
190
  | "session_start"
143
191
  | "resume"
144
192
  | "prompt"
@@ -148,9 +196,9 @@ export type CollectorTrigger =
148
196
  | "session_end"
149
197
  | "trace_flush";
150
198
 
151
- type ChangeTrigger = Extract<CollectorTrigger, "prompt" | "turn_completed" | "fs_change" | "periodic">;
199
+ type ChangeTrigger = Extract<SyncTrigger, "prompt" | "turn_completed" | "fs_change" | "periodic">;
152
200
 
153
- type CollectorFile = {
201
+ type SyncFile = {
154
202
  path: string;
155
203
  content: string;
156
204
  sha256: string;
@@ -160,7 +208,7 @@ type GitRemote = { name: string; url: string };
160
208
  type GitCommit = { sha: string; at: string; subject: string };
161
209
  type GitStatusEntry = { code: string; path: string };
162
210
 
163
- export type CollectorGitBlock = {
211
+ export type SyncGitBlock = {
164
212
  commit: string | null;
165
213
  branch: string | null;
166
214
  dirty: boolean;
@@ -174,7 +222,7 @@ export type CollectorGitBlock = {
174
222
  diff_truncated: boolean;
175
223
  };
176
224
 
177
- export type CollectorEnvironment = {
225
+ export type SyncEnvironment = {
178
226
  os: string;
179
227
  os_version: string;
180
228
  arch: string;
@@ -202,7 +250,7 @@ type ChangeJournalEntry = {
202
250
  };
203
251
 
204
252
  /** Latest model selection observed for a session (from the turn's first assistant message). */
205
- export type CollectorSessionModel = {
253
+ export type SyncSessionModel = {
206
254
  provider_id: string | null;
207
255
  model_id: string | null;
208
256
  variant: string | null;
@@ -210,14 +258,14 @@ export type CollectorSessionModel = {
210
258
  };
211
259
 
212
260
  /** Envelope-level "session" block: latest known values for every snapshot type. */
213
- export type CollectorSessionBlock = {
261
+ export type SyncSessionBlock = {
214
262
  provider_id: string | null;
215
263
  model_id: string | null;
216
264
  variant: string | null;
217
265
  child_session_ids: string[];
218
266
  };
219
267
 
220
- /** A "subagent.model_fallback" trace event of kind "gateway", as the collector keeps it. */
268
+ /** A "subagent.model_fallback" trace event of kind "gateway", as the session uploader keeps it. */
221
269
  type SubagentModelFallback = { requested: string; used: string; effort: string | null; at: number };
222
270
 
223
271
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -263,7 +311,7 @@ function withSubagentFallbacks(messages: unknown[], fallbacks: readonly Subagent
263
311
  });
264
312
  }
265
313
 
266
- export type CollectorChildSession = {
314
+ export type SyncChildSession = {
267
315
  childSessionId: string;
268
316
  parentSessionId: string;
269
317
  /** Sub-agent layer below the root session: 1 for a child, 2 for a grandchild, ... */
@@ -276,13 +324,13 @@ export type CollectorChildSession = {
276
324
  lastMessageId: string | null;
277
325
  };
278
326
 
279
- export type CollectorWebVisit = {
327
+ export type SyncWebVisit = {
280
328
  url: string;
281
329
  title?: string | null;
282
330
  text?: string | null;
283
331
  };
284
332
 
285
- export type CollectorAttachment = {
333
+ export type SyncAttachment = {
286
334
  name: string;
287
335
  mime: string;
288
336
  bytes: number;
@@ -303,7 +351,7 @@ type SessionLedgerRecord = {
303
351
  failureCount?: number;
304
352
  lastFailureAt?: string;
305
353
  lastSuccessAt?: string;
306
- model?: CollectorSessionModel;
354
+ model?: SyncSessionModel;
307
355
  childSessionIds?: string[];
308
356
  /** child session id -> id of its last captured message */
309
357
  childCheckpoints?: Record<string, string>;
@@ -332,7 +380,7 @@ type SpoolMeta = {
332
380
  id: string;
333
381
  session_id: string;
334
382
  snapshot_type: SnapshotType;
335
- trigger: CollectorTrigger;
383
+ trigger: SyncTrigger;
336
384
  sequence: number;
337
385
  bytes: number;
338
386
  created_at: string;
@@ -405,7 +453,7 @@ type SessionState = {
405
453
  lastChangeAt: number;
406
454
  /** Untracked-but-not-ignored files as they stood when the current turn began. */
407
455
  artifactBaseline: Promise<Map<string, ArtifactStat>> | null;
408
- model: CollectorSessionModel | null;
456
+ model: SyncSessionModel | null;
409
457
  childSessionIds: string[];
410
458
  childCheckpoints: Map<string, string>;
411
459
  /**
@@ -422,11 +470,11 @@ type SessionState = {
422
470
  tail: Promise<void>;
423
471
  };
424
472
 
425
- type CollectorOptions = {
473
+ type SyncOptions = {
426
474
  gatewayUrl?: string;
427
475
  accessToken?: string;
428
476
  fetch?: typeof externalFetch;
429
- /** Sends one envelope; `signal` aborts at the upload deadline (collect-upload-budget.ts) or when the spool is cleared. */
477
+ /** Sends one envelope; `signal` aborts at the upload deadline (sync-upload-budget.ts) or when the spool is cleared. */
430
478
  upload?: (sessionId: string, compressed: Uint8Array, signal?: AbortSignal) => Promise<Response>;
431
479
  /**
432
480
  * Asks the account layer for a fresh access token once the gateway rejects
@@ -444,15 +492,15 @@ type CollectorOptions = {
444
492
  uploadRetryDelayMs?: number;
445
493
  retryBaseMs?: number;
446
494
  retryMaxMs?: number;
447
- /** The upload deadline's parameters; COLLECT_UPLOAD_BUDGET unless a test shrinks it. */
448
- uploadBudget?: CollectUploadBudget;
495
+ /** The upload deadline's parameters; SYNC_UPLOAD_BUDGET unless a test shrinks it. */
496
+ uploadBudget?: SyncUploadBudget;
449
497
  spoolMaxEntries?: number;
450
498
  spoolMaxBytes?: number;
451
499
  /** Uncompressed envelope cap; the backend's MAX_SNAPSHOT_BYTES unless a test lowers it. */
452
500
  snapshotMaxBytes?: number;
453
- /** Minimum spacing between change snapshots; MIN_COLLECTOR_CHANGE_INTERVAL_MS unless a test lowers it. */
501
+ /** Minimum spacing between change snapshots; MIN_SYNC_CHANGE_INTERVAL_MS unless a test lowers it. */
454
502
  minChangeIntervalMs?: number;
455
- /** Watcher cap; MAX_COLLECTOR_WATCHED_FILES unless a test lowers it. */
503
+ /** Watcher cap; MAX_SYNC_WATCHED_FILES unless a test lowers it. */
456
504
  maxWatchedFiles?: number;
457
505
  /** Budget of the redacted-text cache; REDACTED_TEXT_CACHE_BYTES unless a test changes it. */
458
506
  redactedTextCacheBytes?: number;
@@ -474,7 +522,7 @@ type CollectorOptions = {
474
522
  /**
475
523
  * CLI: extra top-level envelope fields, written after `session`
476
524
  * (`client_version`, `client_platform`: optional metadata the backend
477
- * indexes; see collector.py `sanitize_snapshot`).
525
+ * indexes; see session uploader.py `sanitize_snapshot`).
478
526
  */
479
527
  envelopeMetadata?: Record<string, string>;
480
528
  };
@@ -712,23 +760,23 @@ const PRIVACY_POLICY = {
712
760
  denied_path_classes: [".env*", "credentials", "keys", "secrets", "tokens", "passwords", ".aws", ".ssh", ".gnupg", ".git", "node_modules", "binaries_over_4MiB"],
713
761
  redaction: ["provider_secrets", "secret_assignments", "private_keys", "pii"],
714
762
  manifest_hash_basis: "sha256_of_redacted_utf8",
715
- max_file_bytes: MAX_COLLECTOR_FILE_BYTES,
763
+ max_file_bytes: MAX_SYNC_FILE_BYTES,
716
764
  max_files: MAX_FILES,
717
- max_diff_bytes: MAX_COLLECTOR_DIFF_BYTES,
765
+ max_diff_bytes: MAX_SYNC_DIFF_BYTES,
718
766
  } as const;
719
767
 
720
768
  /**
721
- * WorkspaceCollector's `enabled` for these options, without building one: an
722
- * upload hook, or a collect URL and a bearer (from the options or the
769
+ * WorkspaceSync's `enabled` for these options, without building one: an
770
+ * upload hook, or an upload URL and a bearer (from the options or the
723
771
  * environment, as the constructor reads them).
724
772
  */
725
- export function workspaceCollectorEnabled(input: { upload: boolean; gatewayUrl?: string; accessToken?: string }): boolean {
773
+ export function workspaceSyncEnabled(input: { upload: boolean; gatewayUrl?: string; accessToken?: string }): boolean {
726
774
  if (input.upload) return true;
727
775
  const token = (input.accessToken ?? process.env.OMNIRUSH_ACCESS_TOKEN ?? "").trim();
728
- return Boolean(resolveCollectUrl(input.gatewayUrl ?? process.env.OMNIRUSH_GATEWAY_URL) && token);
776
+ return Boolean(resolveSyncUrl(input.gatewayUrl ?? process.env.OMNIRUSH_GATEWAY_URL) && token);
729
777
  }
730
778
 
731
- function resolveCollectUrl(rawGatewayUrl: string | undefined): string | null {
779
+ function resolveSyncUrl(rawGatewayUrl: string | undefined): string | null {
732
780
  const value = rawGatewayUrl?.trim();
733
781
  if (!value) return null;
734
782
  try {
@@ -736,7 +784,7 @@ function resolveCollectUrl(rawGatewayUrl: string | undefined): string | null {
736
784
  if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname))) {
737
785
  return null;
738
786
  }
739
- url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "") + "/collect";
787
+ url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "") + UPLOAD_ENDPOINT_PATH;
740
788
  url.search = "";
741
789
  url.hash = "";
742
790
  return url.toString();
@@ -767,15 +815,15 @@ export function workspaceRelativePath(root: string, candidate: string, paths: Pl
767
815
  return path.split(paths.sep).join("/");
768
816
  }
769
817
 
770
- export function collectorPathForUpload(path: string): string {
771
- return redactCollectorText(path).text;
818
+ export function syncPathForUpload(path: string): string {
819
+ return redactSyncText(path).text;
772
820
  }
773
821
 
774
822
  /**
775
823
  * Truncates text to `limit` code points without splitting a surrogate pair.
776
824
  * The backend counts code points, so this never exceeds its limit.
777
825
  */
778
- export function clampCollectorText(text: string, limit: number): string {
826
+ export function clampSyncText(text: string, limit: number): string {
779
827
  if (text.length <= limit) return text;
780
828
  const points = Array.from(text);
781
829
  return points.length <= limit ? text : points.slice(0, limit).join("");
@@ -786,7 +834,7 @@ export function clampCollectorText(text: string, limit: number): string {
786
834
  * Clamps text to a UTF-8 byte budget without splitting a code point. Used for
787
835
  * the free-text fields of trace events whose caps are expressed in bytes.
788
836
  */
789
- export function clampCollectorBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
837
+ export function clampSyncBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
790
838
  if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false };
791
839
  let low = 0;
792
840
  let high = text.length;
@@ -802,16 +850,16 @@ export function clampCollectorBytes(text: string, maxBytes: number): { text: str
802
850
  }
803
851
 
804
852
  /** Redacts then clamps free text destined for a trace event. */
805
- function collectorTextForTrace(text: string | null | undefined, maxBytes: number): { text: string | null; truncated: boolean } {
853
+ function syncTextForTrace(text: string | null | undefined, maxBytes: number): { text: string | null; truncated: boolean } {
806
854
  if (typeof text !== "string") return { text: null, truncated: false };
807
- return clampCollectorBytes(redactCollectorText(text).text, maxBytes);
855
+ return clampSyncBytes(redactSyncText(text).text, maxBytes);
808
856
  }
809
857
 
810
858
  /**
811
859
  * Whether a browser visit may be traced. Local pages, inline documents and
812
860
  * browser-internal URLs never leave the machine.
813
861
  */
814
- export function isCollectableWebUrl(raw: string): boolean {
862
+ export function isSyncableWebUrl(raw: string): boolean {
815
863
  let url: URL;
816
864
  try {
817
865
  url = new URL(raw);
@@ -826,7 +874,7 @@ export function isCollectableWebUrl(raw: string): boolean {
826
874
  }
827
875
 
828
876
  function workspaceRootName(root: string): string {
829
- return clampCollectorText(collectorPathForUpload(root.split(sep).filter(Boolean).at(-1) ?? "workspace"), MAX_ROOT_NAME_CHARS);
877
+ return clampSyncText(syncPathForUpload(root.split(sep).filter(Boolean).at(-1) ?? "workspace"), MAX_ROOT_NAME_CHARS);
830
878
  }
831
879
 
832
880
  function sha256Hex(input: string | Buffer): string {
@@ -860,13 +908,13 @@ function hasScrubbedExtension(lower: string): boolean {
860
908
  }
861
909
 
862
910
  /**
863
- * Whether the collector denies every file under this directory path, whatever
911
+ * Whether the session uploader denies every file under this directory path, whatever
864
912
  * the file is called: one of its components meets the unconditional rules
865
913
  * (`.ssh`, `.aws`, `.gnupg`, `keys`, `secrets`, `credentials*`, `.env*`,
866
914
  * `node_modules`, `.git`, a key-store suffix, ...). The project archive
867
915
  * refuses such a folder as the root of an all-folders archive.
868
916
  */
869
- export function isCollectorDirectoryDenied(path: string): boolean {
917
+ export function isSyncDirectoryDenied(path: string): boolean {
870
918
  return hasDeniedComponent(pathComponents(path));
871
919
  }
872
920
 
@@ -877,7 +925,7 @@ export function isCollectorDirectoryDenied(path: string): boolean {
877
925
  * key", "tokens/prod.csv") denies the file unless it has a source-code or docs
878
926
  * extension, in which case it is kept and scrubbed like any other source file.
879
927
  */
880
- export function isCollectorPathDenied(path: string): boolean {
928
+ export function isSyncPathDenied(path: string): boolean {
881
929
  const parts = pathComponents(path);
882
930
  if (hasDeniedComponent(parts)) return true;
883
931
  const name = parts.at(-1)?.toLowerCase();
@@ -1115,7 +1163,7 @@ function nextAssignmentKeyword(text: string, from: number): number {
1115
1163
  return ASSIGNMENT_LINE_GATE.exec(text)?.index ?? -1;
1116
1164
  }
1117
1165
 
1118
- export type RedactCollectorTextOptions = {
1166
+ export type RedactSyncTextOptions = {
1119
1167
  /** Text that counts as context for context-dependent rules: the file path, or the JSON key a value sits under. */
1120
1168
  context?: string;
1121
1169
  /** Value rule mode; CONFIG when omitted (git diffs, the trace, JSON). See redactModeForPath. */
@@ -1129,7 +1177,7 @@ export type RedactCollectorTextOptions = {
1129
1177
  * assignments, provider token shapes and personal identifiers. The output
1130
1178
  * never contains a dangling backslash, so JSON-escaped text stays escapable.
1131
1179
  */
1132
- export function redactCollectorText(input: string, options: RedactCollectorTextOptions = {}): { text: string; count: number } {
1180
+ export function redactSyncText(input: string, options: RedactSyncTextOptions = {}): { text: string; count: number } {
1133
1181
  const tally = { count: 0 };
1134
1182
  let text = input.includes("-----BEGIN ") ? applyRedaction(input, PRIVATE_KEY_BLOCK, tally) : input;
1135
1183
  if (text.includes("://")) text = applyRedaction(text, URL_USERINFO, tally);
@@ -1194,7 +1242,7 @@ function redactJsonValue(value: unknown, key: string | undefined, tally: JsonTal
1194
1242
  tally.count += 1;
1195
1243
  return REDACTED;
1196
1244
  }
1197
- const result = redactCollectorText(value, { context: key, awsContext });
1245
+ const result = redactSyncText(value, { context: key, awsContext });
1198
1246
  tally.count += result.count;
1199
1247
  return result.text;
1200
1248
  }
@@ -1212,7 +1260,7 @@ function redactJsonValue(value: unknown, key: string | undefined, tally: JsonTal
1212
1260
  tally.members += value.pairs.length;
1213
1261
  const context = awsContext || membersNameAws(value.pairs);
1214
1262
  return new JsonPairs(value.pairs.map(([childKey, childValue]) => {
1215
- const scrubbedKey = redactCollectorText(childKey, { awsContext: context });
1263
+ const scrubbedKey = redactSyncText(childKey, { awsContext: context });
1216
1264
  tally.count += scrubbedKey.count;
1217
1265
  return [scrubbedKey.text, redactJsonValue(childValue, childKey, tally, depth + 1, context, maxDepth)];
1218
1266
  }));
@@ -1225,7 +1273,7 @@ function redactJsonValue(value: unknown, key: string | undefined, tally: JsonTal
1225
1273
  // No prototype, so a `__proto__` key stays an ordinary member.
1226
1274
  const output: Record<string, unknown> = Object.create(null);
1227
1275
  for (const [childKey, childValue] of entries) {
1228
- const scrubbedKey = redactCollectorText(childKey, { awsContext: context });
1276
+ const scrubbedKey = redactSyncText(childKey, { awsContext: context });
1229
1277
  tally.count += scrubbedKey.count;
1230
1278
  output[scrubbedKey.text] = redactJsonValue(childValue, childKey, tally, depth + 1, context, maxDepth);
1231
1279
  }
@@ -1240,7 +1288,7 @@ function redactJsonValue(value: unknown, key: string | undefined, tally: JsonTal
1240
1288
  * never produce a broken escape, which scrubbing serialised JSON as text could.
1241
1289
  * The backend's _redact_json_value.
1242
1290
  */
1243
- export function redactCollectorJson(value: unknown): { value: unknown; count: number } {
1291
+ export function redactSyncJson(value: unknown): { value: unknown; count: number } {
1244
1292
  const tally: JsonTally = { count: 0, members: 0 };
1245
1293
  return { value: redactJsonValue(value, undefined, tally), count: tally.count };
1246
1294
  }
@@ -1358,7 +1406,7 @@ function stringifyJsonPairs(value: unknown, indent: string | undefined, current
1358
1406
  * MAX_JSON_FILE_DEPTH, which the caller scrubs as text. The backend's
1359
1407
  * sanitize_json_file.
1360
1408
  */
1361
- export function redactCollectorJsonText(text: string, path?: string): string | null {
1409
+ export function redactSyncJsonText(text: string, path?: string): string | null {
1362
1410
  const raw = redactAwsSecrets(text, path, { count: 0 });
1363
1411
  let parsed: unknown;
1364
1412
  try {
@@ -1391,17 +1439,17 @@ export function redactCollectorJsonText(text: string, path?: string): string | n
1391
1439
  * back to text when they do not parse), everything else as text with the path
1392
1440
  * as context and the value rule mode the extension selects.
1393
1441
  */
1394
- export function redactCollectorContent(path: string, text: string): string {
1395
- return redactCollectorContentCounted(path, text).text;
1442
+ export function redactSyncContent(path: string, text: string): string {
1443
+ return redactSyncContentCounted(path, text).text;
1396
1444
  }
1397
1445
 
1398
- /** redactCollectorContent plus how many redactions it made (0 means the text is byte-identical). */
1399
- function redactCollectorContentCounted(path: string, text: string): { text: string; count: number } {
1446
+ /** redactSyncContent plus how many redactions it made (0 means the text is byte-identical). */
1447
+ function redactSyncContentCounted(path: string, text: string): { text: string; count: number } {
1400
1448
  if (/\.json$/i.test(path)) {
1401
- const json = redactCollectorJsonText(text, path);
1449
+ const json = redactSyncJsonText(text, path);
1402
1450
  if (json !== null) return { text: json, count: json === text ? 0 : 1 };
1403
1451
  }
1404
- return redactCollectorText(text, { context: path, mode: redactModeForPath(path) });
1452
+ return redactSyncText(text, { context: path, mode: redactModeForPath(path) });
1405
1453
  }
1406
1454
 
1407
1455
  /**
@@ -1556,7 +1604,7 @@ function gitIgnoredPaths(root: string, paths: string[]): Promise<Set<string> | n
1556
1604
  * one bad path never changes the answer for the others (and never sends them
1557
1605
  * to the .gitignore-only fallback, which knows nothing of `.git/info/exclude`
1558
1606
  * or `core.excludesFile`). A path git refuses on its own counts as ignored:
1559
- * it is never collected. Null only when the workspace is not a git work tree
1607
+ * it is never uploaded. Null only when the workspace is not a git work tree
1560
1608
  * (or git is missing), where the caller applies the .gitignore files itself.
1561
1609
  * `onSpawn` counts each `git check-ignore` run.
1562
1610
  */
@@ -1630,10 +1678,10 @@ function nullableString(value: unknown): string | null {
1630
1678
  return typeof value === "string" && value.length > 0 ? value : null;
1631
1679
  }
1632
1680
 
1633
- function ledgerModel(value: unknown): CollectorSessionModel | undefined {
1681
+ function ledgerModel(value: unknown): SyncSessionModel | undefined {
1634
1682
  if (!value || typeof value !== "object") return undefined;
1635
1683
  const record = value as Record<string, unknown>;
1636
- const model: CollectorSessionModel = {
1684
+ const model: SyncSessionModel = {
1637
1685
  provider_id: nullableString(record.provider_id),
1638
1686
  model_id: nullableString(record.model_id),
1639
1687
  variant: nullableString(record.variant),
@@ -1703,7 +1751,7 @@ function boundedTracePayload(
1703
1751
  // dangling backslash that made the whole document unparseable. Header and
1704
1752
  // events are scrubbed as the one document the backend scrubs, so a header
1705
1753
  // value naming an AWS key is container context for the events there too.
1706
- const { events: scrubbed, ...header } = redactCollectorJson({
1754
+ const { events: scrubbed, ...header } = redactSyncJson({
1707
1755
  schema_version: 1,
1708
1756
  session_id: state.id,
1709
1757
  workspace_id: state.workspaceId,
@@ -1726,7 +1774,7 @@ function boundedTracePayload(
1726
1774
  // is its wrapper plus the events' serialised bytes and the commas between
1727
1775
  // them, so the longest prefix that fits follows from a running total. Every
1728
1776
  // probe used to be a full serialisation of the prefix, which made a flush
1729
- // at the MAX_COLLECTOR_TRACE_EVENTS cap peak at many times the payload.
1777
+ // at the MAX_SYNC_TRACE_EVENTS cap peak at many times the payload.
1730
1778
  let payload = encode(scrubbed, false);
1731
1779
  if (payload.byteLength <= maxBytes) return { content: payload.content, events: payload.events };
1732
1780
  const wrapperBytes = (included: number) => Buffer.byteLength(JSON.stringify({
@@ -1786,7 +1834,7 @@ async function walkFallback(root: string, directory = root, rules: string[] = []
1786
1834
  if (hasDeniedComponent(pathComponents(path))) listing.denied += 1;
1787
1835
  else await walkFallback(root, fullPath, ignoreRules, listing);
1788
1836
  } else if (entry.isFile()) {
1789
- if (isCollectorPathDenied(path)) listing.denied += 1;
1837
+ if (isSyncPathDenied(path)) listing.denied += 1;
1790
1838
  else listing.paths.push(path);
1791
1839
  }
1792
1840
  }
@@ -1797,7 +1845,7 @@ function filterListing(candidates: string[]): WorkspaceListing {
1797
1845
  const listing: WorkspaceListing = { paths: [], denied: 0 };
1798
1846
  for (const path of candidates) {
1799
1847
  if (!path) continue;
1800
- if (isCollectorPathDenied(path)) listing.denied += 1;
1848
+ if (isSyncPathDenied(path)) listing.denied += 1;
1801
1849
  else if (listing.paths.length < MAX_FILES) listing.paths.push(path);
1802
1850
  }
1803
1851
  listing.paths.sort((left, right) => left.localeCompare(right));
@@ -1901,9 +1949,9 @@ function parseGitStatus(raw: string, prefix = ""): { entries: GitStatusEntry[];
1901
1949
  const path = repoPath.slice(prefix.length);
1902
1950
  if (!path) continue;
1903
1951
  if (code !== "??" && code !== "!!") dirty = true;
1904
- if (isCollectorPathDenied(path)) continue;
1952
+ if (isSyncPathDenied(path)) continue;
1905
1953
  if (entries.length >= MAX_GIT_STATUS_ENTRIES) continue;
1906
- const uploadPath = collectorPathForUpload(path);
1954
+ const uploadPath = syncPathForUpload(path);
1907
1955
  if (uploadPath.length > MAX_GIT_PATH_CHARS) continue;
1908
1956
  entries.push({ code, path: uploadPath });
1909
1957
  }
@@ -1921,8 +1969,8 @@ function parseGitRemotes(raw: string): GitRemote[] {
1921
1969
  return [...remotes.entries()]
1922
1970
  .slice(0, MAX_GIT_REMOTES)
1923
1971
  .map(([name, url]) => ({
1924
- name: clampCollectorText(collectorPathForUpload(name), MAX_GIT_REMOTE_NAME_CHARS),
1925
- url: clampCollectorText(collectorPathForUpload(stripRemoteUserinfo(url)), MAX_GIT_REMOTE_URL_CHARS),
1972
+ name: clampSyncText(syncPathForUpload(name), MAX_GIT_REMOTE_NAME_CHARS),
1973
+ url: clampSyncText(syncPathForUpload(stripRemoteUserinfo(url)), MAX_GIT_REMOTE_URL_CHARS),
1926
1974
  }));
1927
1975
  }
1928
1976
 
@@ -1931,7 +1979,7 @@ function parseGitLog(raw: string): GitCommit[] {
1931
1979
  for (const record of raw.split("\0")) {
1932
1980
  const [sha, at, subject] = record.replace(/^\n/, "").split("\x1f");
1933
1981
  if (!sha || !/^[0-9a-f]{7,64}$/.test(sha) || !at) continue;
1934
- commits.push({ sha, at, subject: clampCollectorText(collectorPathForUpload(subject ?? ""), MAX_GIT_SUBJECT_CHARS) });
1982
+ commits.push({ sha, at, subject: clampSyncText(syncPathForUpload(subject ?? ""), MAX_GIT_SUBJECT_CHARS) });
1935
1983
  if (commits.length >= MAX_GIT_RECENT_COMMITS) break;
1936
1984
  }
1937
1985
  return commits;
@@ -1995,7 +2043,7 @@ export function diffHeaderPath(header: string): string | null {
1995
2043
  * them, and caps the result. Sections whose header cannot be parsed are
1996
2044
  * dropped: an unreadable path must never leak a denied file's contents.
1997
2045
  */
1998
- export function filterCollectorDiff(raw: string, inputTruncated = false): { diff: string | null; truncated: boolean } {
2046
+ export function filterSyncDiff(raw: string, inputTruncated = false): { diff: string | null; truncated: boolean } {
1999
2047
  let output = "";
2000
2048
  let used = 0;
2001
2049
  let truncated = inputTruncated;
@@ -2003,15 +2051,15 @@ export function filterCollectorDiff(raw: string, inputTruncated = false): { diff
2003
2051
  if (!section.startsWith("diff --git ")) continue;
2004
2052
  const headerEnd = section.indexOf("\n");
2005
2053
  const path = diffHeaderPath(headerEnd === -1 ? section : section.slice(0, headerEnd));
2006
- if (!path || isCollectorPathDenied(path)) continue;
2054
+ if (!path || isSyncPathDenied(path)) continue;
2007
2055
  if (/^(?:Binary files .* differ|GIT binary patch)/m.test(section)) continue;
2008
2056
  // A diff is text even when the file is JSON: hunks are not parseable documents.
2009
- const redacted = redactCollectorText(section, { context: path }).text;
2057
+ const redacted = redactSyncText(section, { context: path }).text;
2010
2058
  const size = Buffer.byteLength(redacted);
2011
- if (used + size > MAX_COLLECTOR_DIFF_BYTES) {
2059
+ if (used + size > MAX_SYNC_DIFF_BYTES) {
2012
2060
  truncated = true;
2013
2061
  if (used === 0) {
2014
- output = Buffer.from(redacted).subarray(0, MAX_COLLECTOR_DIFF_BYTES).toString("utf8").replace(/�+$/, "");
2062
+ output = Buffer.from(redacted).subarray(0, MAX_SYNC_DIFF_BYTES).toString("utf8").replace(/�+$/, "");
2015
2063
  }
2016
2064
  break;
2017
2065
  }
@@ -2034,7 +2082,7 @@ async function gitDiff(root: string, hasHead: boolean): Promise<{ diff: string |
2034
2082
  const options = { maxBytes: GIT_DIFF_READ_BYTES, timeoutMs: 20_000 };
2035
2083
  if (hasHead) {
2036
2084
  const result = await runGit(root, ["diff", "HEAD", ...common, ...scope], options);
2037
- if (result.ok) return filterCollectorDiff(result.stdout.toString("utf8"), result.truncated);
2085
+ if (result.ok) return filterSyncDiff(result.stdout.toString("utf8"), result.truncated);
2038
2086
  }
2039
2087
  // An unborn branch has no HEAD to diff against: combine the index (against
2040
2088
  // the empty tree) with the working tree changes on top of it.
@@ -2044,7 +2092,7 @@ async function gitDiff(root: string, hasHead: boolean): Promise<{ diff: string |
2044
2092
  ]);
2045
2093
  if (!staged.ok && !unstaged.ok) return { diff: null, truncated: false };
2046
2094
  const text = `${staged.ok ? staged.stdout.toString("utf8") : ""}${unstaged.ok ? unstaged.stdout.toString("utf8") : ""}`;
2047
- return filterCollectorDiff(text, staged.truncated || unstaged.truncated);
2095
+ return filterSyncDiff(text, staged.truncated || unstaged.truncated);
2048
2096
  }
2049
2097
 
2050
2098
  /**
@@ -2054,7 +2102,7 @@ async function gitDiff(root: string, hasHead: boolean): Promise<{ diff: string |
2054
2102
  * upstream, remotes without userinfo, recent subjects) and what is in flight
2055
2103
  * (porcelain status and the combined text diff), all redacted.
2056
2104
  */
2057
- export async function collectGitBlock(root: string): Promise<CollectorGitBlock | null> {
2105
+ export async function readGitBlock(root: string): Promise<SyncGitBlock | null> {
2058
2106
  const inside = await gitText(root, ["rev-parse", "--is-inside-work-tree"]);
2059
2107
  if (inside !== "true") return null;
2060
2108
  // Where the workspace sits inside the repository ("" at the top level,
@@ -2087,9 +2135,9 @@ export async function collectGitBlock(root: string): Promise<CollectorGitBlock |
2087
2135
  const diff = await gitDiff(root, Boolean(commit));
2088
2136
  return {
2089
2137
  commit,
2090
- branch: branch ? clampCollectorText(collectorPathForUpload(branch), MAX_GIT_REF_CHARS) : null,
2138
+ branch: branch ? clampSyncText(syncPathForUpload(branch), MAX_GIT_REF_CHARS) : null,
2091
2139
  dirty: parsedStatus.dirty,
2092
- upstream: upstream ? clampCollectorText(collectorPathForUpload(upstream), MAX_GIT_REF_CHARS) : null,
2140
+ upstream: upstream ? clampSyncText(syncPathForUpload(upstream), MAX_GIT_REF_CHARS) : null,
2093
2141
  ahead,
2094
2142
  behind,
2095
2143
  remotes: remotes ? parseGitRemotes(remotes) : [],
@@ -2194,7 +2242,7 @@ class ReadPool {
2194
2242
 
2195
2243
  /** Runs `use` with the file's bytes; the view must not escape `use`. */
2196
2244
  async read<T>(absolute: string, expectedSize: number, use: (bytes: Buffer) => Promise<T> | T): Promise<T | null> {
2197
- if (expectedSize > MAX_COLLECTOR_FILE_BYTES) return null;
2245
+ if (expectedSize > MAX_SYNC_FILE_BYTES) return null;
2198
2246
  if (expectedSize < this.capacity) {
2199
2247
  const buffer = await this.acquire();
2200
2248
  try {
@@ -2210,10 +2258,10 @@ class ReadPool {
2210
2258
 
2211
2259
  private readLarge<T>(absolute: string, use: (bytes: Buffer) => Promise<T> | T): Promise<T | null> {
2212
2260
  const run = this.largeTail.then(async () => {
2213
- this.large ??= Buffer.allocUnsafeSlow(MAX_COLLECTOR_FILE_BYTES + 1);
2261
+ this.large ??= Buffer.allocUnsafeSlow(MAX_SYNC_FILE_BYTES + 1);
2214
2262
  const length = await readInto(absolute, this.large);
2215
- // Past the cap by now: not a file the collector takes.
2216
- return length > MAX_COLLECTOR_FILE_BYTES ? null : await use(this.large.subarray(0, length));
2263
+ // Past the cap by now: not a file the session uploader takes.
2264
+ return length > MAX_SYNC_FILE_BYTES ? null : await use(this.large.subarray(0, length));
2217
2265
  });
2218
2266
  this.largeTail = run.catch(() => undefined);
2219
2267
  return run;
@@ -2310,7 +2358,7 @@ export async function mapBounded<T, R>(items: readonly T[], limit: number, opera
2310
2358
  }
2311
2359
 
2312
2360
  /** Work counters for tests and profiling; never uploaded. */
2313
- export type CollectorMetrics = {
2361
+ export type SyncMetrics = {
2314
2362
  fileStats: number;
2315
2363
  fileReads: number;
2316
2364
  fileRedactions: number;
@@ -2328,7 +2376,7 @@ export type CollectorMetrics = {
2328
2376
  redactedTextHits: number;
2329
2377
  };
2330
2378
 
2331
- function freshMetrics(): CollectorMetrics {
2379
+ function freshMetrics(): SyncMetrics {
2332
2380
  return {
2333
2381
  fileStats: 0, fileReads: 0, fileRedactions: 0, fullScans: 0, dirtyScans: 0, reconciles: 0,
2334
2382
  capturesSkipped: 0, capturesDeferred: 0, capturesHeld: 0, ignoreCheckSpawns: 0, watchEvents: 0, envelopesWritten: 0, redactedTextHits: 0,
@@ -2346,10 +2394,10 @@ function redactedCacheEntry(
2346
2394
  size: number,
2347
2395
  mtimeMs: number,
2348
2396
  uploadPath: string,
2349
- metrics: CollectorMetrics,
2397
+ metrics: SyncMetrics,
2350
2398
  texts?: { cache: RedactedTextCache; absolute: string },
2351
2399
  ): { entry: HashCacheEntry; text: string } {
2352
- const redacted = redactCollectorContentCounted(path, buffer.toString("utf8"));
2400
+ const redacted = redactSyncContentCounted(path, buffer.toString("utf8"));
2353
2401
  metrics.fileRedactions += 1;
2354
2402
  const entry: HashCacheEntry = {
2355
2403
  size,
@@ -2371,7 +2419,7 @@ function redactedCacheEntry(
2371
2419
  /** The redacted upload path, sharing the listing's string when redaction left it unchanged. */
2372
2420
  function uploadPathFor(path: string, cached: HashCacheEntry | undefined): string {
2373
2421
  if (cached) return cached.uploadPath;
2374
- const uploadPath = collectorPathForUpload(path);
2422
+ const uploadPath = syncPathForUpload(path);
2375
2423
  return uploadPath === path ? path : uploadPath;
2376
2424
  }
2377
2425
 
@@ -2409,13 +2457,13 @@ type ScanContext = {
2409
2457
  rootPrefix: string;
2410
2458
  cache: Map<string, HashCacheEntry>;
2411
2459
  texts: RedactedTextCache;
2412
- metrics: CollectorMetrics;
2460
+ metrics: SyncMetrics;
2413
2461
  yielder: LoopYielder;
2414
2462
  pool: ReadPool;
2415
2463
  ancestors: AncestorCache;
2416
2464
  };
2417
2465
 
2418
- function scanContext(root: string, cache: Map<string, HashCacheEntry>, texts: RedactedTextCache, metrics: CollectorMetrics): ScanContext {
2466
+ function scanContext(root: string, cache: Map<string, HashCacheEntry>, texts: RedactedTextCache, metrics: SyncMetrics): ScanContext {
2419
2467
  const base = resolve(root);
2420
2468
  return {
2421
2469
  root,
@@ -2448,7 +2496,7 @@ async function inspectFile(context: ScanContext, path: string): Promise<HashCach
2448
2496
  return null;
2449
2497
  }
2450
2498
  metrics.fileStats += 1;
2451
- if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_COLLECTOR_FILE_BYTES) return null;
2499
+ if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_SYNC_FILE_BYTES) return null;
2452
2500
  const cached = cache.get(path);
2453
2501
  if (cached && cached.size === file.size && cached.mtimeMs === file.mtimeMs) return cached;
2454
2502
  const uploadPath = uploadPathFor(path, cached);
@@ -2485,7 +2533,7 @@ async function readUploadContent(
2485
2533
  path: string,
2486
2534
  cache: Map<string, HashCacheEntry>,
2487
2535
  texts: RedactedTextCache,
2488
- metrics: CollectorMetrics,
2536
+ metrics: SyncMetrics,
2489
2537
  pool: ReadPool,
2490
2538
  ancestors: AncestorCache,
2491
2539
  ): Promise<UploadContent | null> {
@@ -2498,7 +2546,7 @@ async function readUploadContent(
2498
2546
  } catch {
2499
2547
  return null;
2500
2548
  }
2501
- if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_COLLECTOR_FILE_BYTES) return null;
2549
+ if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_SYNC_FILE_BYTES) return null;
2502
2550
  const cached = cache.get(path);
2503
2551
  try {
2504
2552
  return await pool.read(absolute, file.size, (buffer): UploadContent | null => {
@@ -2559,7 +2607,7 @@ type SnapshotCandidate = {
2559
2607
  };
2560
2608
 
2561
2609
  /**
2562
- * The workspace as a turn began, by path: null for a file the collector only
2610
+ * The workspace as a turn began, by path: null for a file the session uploader only
2563
2611
  * ever saw once the turn had written it (it was there, but what it held
2564
2612
  * before the turn is unknown).
2565
2613
  */
@@ -2665,7 +2713,7 @@ async function scanWorkspaceFull(
2665
2713
  texts: RedactedTextCache,
2666
2714
  previous: Manifest | null,
2667
2715
  includeAll: boolean,
2668
- metrics: CollectorMetrics,
2716
+ metrics: SyncMetrics,
2669
2717
  onListing?: (paths: readonly string[]) => void,
2670
2718
  ): Promise<ScanResult> {
2671
2719
  metrics.fullScans += 1;
@@ -2712,7 +2760,7 @@ async function scanDirtyPaths(
2712
2760
  dirty: readonly string[],
2713
2761
  ignored: ReadonlySet<string>,
2714
2762
  listing: { denied: number; truncated: boolean },
2715
- metrics: CollectorMetrics,
2763
+ metrics: SyncMetrics,
2716
2764
  ): Promise<ScanResult> {
2717
2765
  metrics.dirtyScans += 1;
2718
2766
  const context = scanContext(root, cache, texts, metrics);
@@ -2765,13 +2813,13 @@ async function scanDirtyPaths(
2765
2813
  * Paths whose content a capped snapshot keeps first: what the session touched
2766
2814
  * plus what git reports as modified, keyed the way files[] names them.
2767
2815
  */
2768
- function snapshotPriorityPaths(touched: Iterable<string>, git: CollectorGitBlock | null): Set<string> {
2816
+ function snapshotPriorityPaths(touched: Iterable<string>, git: SyncGitBlock | null): Set<string> {
2769
2817
  const paths = new Set<string>();
2770
- for (const path of touched) paths.add(collectorPathForUpload(path));
2818
+ for (const path of touched) paths.add(syncPathForUpload(path));
2771
2819
  for (const entry of git?.status ?? []) paths.add(entry.path);
2772
2820
  for (const line of git?.diff?.split("\n") ?? []) {
2773
2821
  const path = diffHeaderPath(line);
2774
- if (path) paths.add(collectorPathForUpload(path));
2822
+ if (path) paths.add(syncPathForUpload(path));
2775
2823
  }
2776
2824
  return paths;
2777
2825
  }
@@ -2850,7 +2898,7 @@ const JSON_STRING_PIECE_CHARS = 1 << 20;
2850
2898
  /**
2851
2899
  * Writes `value` as JSON, byte for byte what JSON.stringify(value) gives,
2852
2900
  * without building the whole text: a plain object's long string values (a
2853
- * file's content, up to MAX_COLLECTOR_FILE_BYTES; the trace document, up to
2901
+ * file's content, up to MAX_SYNC_FILE_BYTES; the trace document, up to
2854
2902
  * MAX_TRACE_BYTES) are escaped in pieces, so no escaped copy of them exists
2855
2903
  * at once. Anything else goes through JSON.stringify.
2856
2904
  */
@@ -2961,14 +3009,14 @@ type EnvelopeBody = {
2961
3009
  /** Envelope fields written before files[]: workspace, environment, touched paths, scope. */
2962
3010
  extras: Record<string, unknown>;
2963
3011
  /** Streams files[] one entry at a time through `emit`. */
2964
- writeFiles: (emit: (file: CollectorFile) => Promise<void>) => Promise<void>;
3012
+ writeFiles: (emit: (file: SyncFile) => Promise<void>) => Promise<void>;
2965
3013
  manifest: () => Iterable<HashCacheEntry>;
2966
3014
  /** Computed once files[] is written, so cap omissions are known. */
2967
3015
  privacy: () => Record<string, unknown>;
2968
3016
  trace?: unknown[];
2969
3017
  };
2970
3018
 
2971
- function collectorEnvironment(appVersion: string | undefined, engineVersion: string | undefined, gitVersion: string | null): CollectorEnvironment {
3019
+ function syncEnvironment(appVersion: string | undefined, engineVersion: string | undefined, gitVersion: string | null): SyncEnvironment {
2972
3020
  let locale: string | null = null;
2973
3021
  let timezone: string | null = null;
2974
3022
  try {
@@ -2980,7 +3028,7 @@ function collectorEnvironment(appVersion: string | undefined, engineVersion: str
2980
3028
  }
2981
3029
  const shellPath = process.platform === "win32" ? process.env.COMSPEC : process.env.SHELL;
2982
3030
  const field = (value: string | null | undefined): string | null =>
2983
- value?.trim() ? clampCollectorText(value.trim(), MAX_ENVIRONMENT_FIELD_CHARS) : null;
3031
+ value?.trim() ? clampSyncText(value.trim(), MAX_ENVIRONMENT_FIELD_CHARS) : null;
2984
3032
  return {
2985
3033
  os: process.platform,
2986
3034
  os_version: field(osRelease()) ?? "",
@@ -3078,7 +3126,7 @@ function parseSpoolMeta(value: unknown): SpoolMeta | null {
3078
3126
  id: record.id,
3079
3127
  session_id: record.session_id,
3080
3128
  snapshot_type: record.snapshot_type as SnapshotType,
3081
- trigger: record.trigger as CollectorTrigger,
3129
+ trigger: record.trigger as SyncTrigger,
3082
3130
  sequence: record.sequence,
3083
3131
  bytes: record.bytes,
3084
3132
  created_at: record.created_at,
@@ -3087,13 +3135,13 @@ function parseSpoolMeta(value: unknown): SpoolMeta | null {
3087
3135
  };
3088
3136
  }
3089
3137
 
3090
- export class WorkspaceCollector {
3091
- private readonly collectUrl: string | null;
3138
+ export class WorkspaceSync {
3139
+ private readonly syncUrl: string | null;
3092
3140
  private token: string;
3093
3141
  private readonly fetcher: typeof externalFetch;
3094
- private readonly uploader?: CollectorOptions["upload"];
3095
- private readonly refreshAccessToken?: CollectorOptions["refreshAccessToken"];
3096
- private readonly log: NonNullable<CollectorOptions["log"]>;
3142
+ private readonly uploader?: SyncOptions["upload"];
3143
+ private readonly refreshAccessToken?: SyncOptions["refreshAccessToken"];
3144
+ private readonly log: NonNullable<SyncOptions["log"]>;
3097
3145
  private readonly ledgerPath: string | null;
3098
3146
  private readonly spoolDir: string | null;
3099
3147
  private readonly ledgerReady: Promise<void>;
@@ -3107,11 +3155,11 @@ export class WorkspaceCollector {
3107
3155
  private readonly uploadRetryDelayMs: number;
3108
3156
  private readonly retryBaseMs: number;
3109
3157
  private readonly retryMaxMs: number;
3110
- private readonly uploadBudget: CollectUploadBudget;
3158
+ private readonly uploadBudget: SyncUploadBudget;
3111
3159
  private readonly spoolMaxEntries: number;
3112
3160
  private readonly spoolMaxBytes: number;
3113
3161
  private readonly snapshotMaxBytes: number;
3114
- private environmentCache: Promise<CollectorEnvironment> | null = null;
3162
+ private environmentCache: Promise<SyncEnvironment> | null = null;
3115
3163
  private spoolCounter = 0;
3116
3164
  private spoolTail: Promise<void> = Promise.resolve();
3117
3165
  private retryTimer: ReturnType<typeof setTimeout> | null = null;
@@ -3146,16 +3194,17 @@ export class WorkspaceCollector {
3146
3194
  private readonly watch: boolean;
3147
3195
  private readonly envelopeMetadata: Record<string, string>;
3148
3196
  /** Work counters for tests and profiling. */
3149
- readonly metrics: CollectorMetrics = freshMetrics();
3197
+ readonly metrics: SyncMetrics = freshMetrics();
3150
3198
 
3151
- constructor(options: CollectorOptions = {}) {
3152
- this.collectUrl = resolveCollectUrl(options.gatewayUrl ?? process.env.OMNIRUSH_GATEWAY_URL);
3199
+ constructor(options: SyncOptions = {}) {
3200
+ this.syncUrl = resolveSyncUrl(options.gatewayUrl ?? process.env.OMNIRUSH_GATEWAY_URL);
3153
3201
  this.token = (options.accessToken ?? process.env.OMNIRUSH_ACCESS_TOKEN ?? "").trim();
3154
3202
  this.fetcher = options.fetch ?? externalFetch;
3155
3203
  this.uploader = options.upload;
3156
3204
  this.refreshAccessToken = options.refreshAccessToken;
3157
3205
  this.log = options.log ?? (() => undefined);
3158
3206
  const stateDir = options.stateDir ? resolve(options.stateDir) : null;
3207
+ if (stateDir) migrateLegacyStateNames(stateDir);
3159
3208
  this.ledgerPath = stateDir ? join(stateDir, SESSION_LEDGER_FILE) : null;
3160
3209
  this.spoolDir = stateDir ? join(stateDir, SPOOL_DIRECTORY) : null;
3161
3210
  this.ledgerReady = this.loadLedger();
@@ -3166,18 +3215,18 @@ export class WorkspaceCollector {
3166
3215
  this.uploadRetryDelayMs = options.uploadRetryDelayMs ?? UPLOAD_RETRY_DELAY_MS;
3167
3216
  this.retryBaseMs = options.retryBaseMs ?? RETRY_BASE_MS;
3168
3217
  this.retryMaxMs = options.retryMaxMs ?? RETRY_MAX_MS;
3169
- this.uploadBudget = options.uploadBudget ?? COLLECT_UPLOAD_BUDGET;
3218
+ this.uploadBudget = options.uploadBudget ?? SYNC_UPLOAD_BUDGET;
3170
3219
  this.spoolMaxEntries = options.spoolMaxEntries ?? MAX_SPOOL_ENTRIES;
3171
3220
  this.spoolMaxBytes = options.spoolMaxBytes ?? MAX_SPOOL_BYTES;
3172
3221
  this.snapshotMaxBytes = options.snapshotMaxBytes ?? MAX_SNAPSHOT_BYTES;
3173
- this.minChangeIntervalMs = options.minChangeIntervalMs ?? MIN_COLLECTOR_CHANGE_INTERVAL_MS;
3174
- this.maxWatchedFiles = options.maxWatchedFiles ?? MAX_COLLECTOR_WATCHED_FILES;
3222
+ this.minChangeIntervalMs = options.minChangeIntervalMs ?? MIN_SYNC_CHANGE_INTERVAL_MS;
3223
+ this.maxWatchedFiles = options.maxWatchedFiles ?? MAX_SYNC_WATCHED_FILES;
3175
3224
  this.texts = new RedactedTextCache(options.redactedTextCacheBytes ?? REDACTED_TEXT_CACHE_BYTES);
3176
3225
  this.onSessionClosed = options.onSessionClosed;
3177
3226
  this.onPathTouched = options.onPathTouched;
3178
3227
  this.watch = options.watch !== false;
3179
3228
  this.envelopeMetadata = { ...(options.envelopeMetadata ?? {}) };
3180
- this.tempDir = stateDir ? join(stateDir, TEMP_DIRECTORY) : join(tmpdir(), `omnirush-collector-${process.pid}`);
3229
+ this.tempDir = stateDir ? join(stateDir, TEMP_DIRECTORY) : join(tmpdir(), `omnirush-upload-${process.pid}`);
3181
3230
  this.bases = new TurnBaseStore(stateDir ? join(stateDir, BASE_DIRECTORY) : join(this.tempDir, BASE_DIRECTORY));
3182
3231
  void this.cleanTempDir(60 * 60_000).catch(() => undefined);
3183
3232
  if (this.spoolDir) {
@@ -3318,10 +3367,10 @@ export class WorkspaceCollector {
3318
3367
  }
3319
3368
 
3320
3369
  get enabled(): boolean {
3321
- return Boolean(this.uploader || (this.collectUrl && this.token));
3370
+ return Boolean(this.uploader || (this.syncUrl && this.token));
3322
3371
  }
3323
3372
 
3324
- private environment(): Promise<CollectorEnvironment> {
3373
+ private environment(): Promise<SyncEnvironment> {
3325
3374
  this.environmentCache ??= (async () => {
3326
3375
  let gitVersion: string | null = null;
3327
3376
  try {
@@ -3330,7 +3379,7 @@ export class WorkspaceCollector {
3330
3379
  } catch {
3331
3380
  gitVersion = null;
3332
3381
  }
3333
- return collectorEnvironment(this.appVersion, this.engineVersion, gitVersion);
3382
+ return syncEnvironment(this.appVersion, this.engineVersion, gitVersion);
3334
3383
  })();
3335
3384
  return this.environmentCache;
3336
3385
  }
@@ -3464,7 +3513,7 @@ export class WorkspaceCollector {
3464
3513
  state.watchMode = "polling";
3465
3514
  state.dirtyOverflow = true;
3466
3515
  this.stopReconcile(state);
3467
- this.log("info", "OmniRush collection polls this workspace on snapshots instead of watching it", {
3516
+ this.log("info", "OmniRush session upload: polls this workspace on snapshots instead of watching it", {
3468
3517
  sessionId: state.id,
3469
3518
  files,
3470
3519
  limit: this.maxWatchedFiles,
@@ -3516,7 +3565,7 @@ export class WorkspaceCollector {
3516
3565
  const path = workspaceRelativePath(state.root, prefix ? `${prefix}/${relative}` : relative);
3517
3566
  if (!path) return;
3518
3567
  this.reportTouched(state, path);
3519
- if (isCollectorPathDenied(path)) return;
3568
+ if (isSyncPathDenied(path)) return;
3520
3569
  if (path === ".gitignore" || path.endsWith("/.gitignore")) {
3521
3570
  // New ignore rules can hide or reveal any number of files: forget what
3522
3571
  // git answered so far and rescan the tree with the rules as they stand.
@@ -3615,7 +3664,7 @@ export class WorkspaceCollector {
3615
3664
  await mapBounded(listing.paths, SCAN_CONCURRENCY, async (path) => {
3616
3665
  const file = await lstat(resolve(state.root, path)).catch(() => null);
3617
3666
  if (file) this.metrics.fileStats += 1;
3618
- const eligible = file && file.isFile() && !file.isSymbolicLink() && file.size <= MAX_COLLECTOR_FILE_BYTES ? file : null;
3667
+ const eligible = file && file.isFile() && !file.isSymbolicLink() && file.size <= MAX_SYNC_FILE_BYTES ? file : null;
3619
3668
  const cached = cache.get(path);
3620
3669
  for (const session of sessions) {
3621
3670
  const known = session.manifest?.get(path);
@@ -3697,7 +3746,7 @@ export class WorkspaceCollector {
3697
3746
  * asks git about every path whatever the cache holds, and refreshes the
3698
3747
  * cache with the answers: a snapshot decides what leaves the machine on
3699
3748
  * that answer, since `.git/info/exclude` or `core.excludesFile` can change
3700
- * with no event the collector sees.
3749
+ * with no event the session uploader sees.
3701
3750
  */
3702
3751
  private async ignoredPaths(state: SessionState, paths: string[], fresh = false): Promise<Set<string>> {
3703
3752
  const ignored = new Set<string>();
@@ -3796,24 +3845,24 @@ export class WorkspaceCollector {
3796
3845
  this.appendTrace(state, type, data);
3797
3846
  }
3798
3847
 
3799
- /** Appends one trace event, keeping only the newest MAX_COLLECTOR_TRACE_EVENTS. */
3848
+ /** Appends one trace event, keeping only the newest MAX_SYNC_TRACE_EVENTS. */
3800
3849
  private appendTrace(state: SessionState, type: string, data?: unknown): void {
3801
3850
  state.trace.push({ at: new Date().toISOString(), type, ...(data === undefined ? {} : { data }) });
3802
- if (state.trace.length > MAX_COLLECTOR_TRACE_EVENTS) state.trace.splice(0, state.trace.length - MAX_COLLECTOR_TRACE_EVENTS);
3851
+ if (state.trace.length > MAX_SYNC_TRACE_EVENTS) state.trace.splice(0, state.trace.length - MAX_SYNC_TRACE_EVENTS);
3803
3852
  }
3804
3853
 
3805
3854
  private recordTouchedPath(state: SessionState, candidate: string): void {
3806
3855
  const path = workspaceRelativePath(state.root, candidate.replaceAll("\\", "/"));
3807
3856
  if (!path) return;
3808
3857
  this.reportTouched(state, path);
3809
- if (isCollectorPathDenied(path) || state.touchedPaths.has(path)) return;
3858
+ if (isSyncPathDenied(path) || state.touchedPaths.has(path)) return;
3810
3859
  state.touchedPaths.add(path);
3811
3860
  this.markDirty(state, path);
3812
3861
  this.queueChangedPath(state, path);
3813
3862
  this.scheduleChange(state, "fs_change");
3814
3863
  }
3815
3864
 
3816
- /** Whether the collector is currently tracking this session. */
3865
+ /** Whether the session uploader is currently tracking this session. */
3817
3866
  hasSession(sessionId: string): boolean {
3818
3867
  const state = this.sessions.get(sessionId);
3819
3868
  return Boolean(state && !state.finished);
@@ -3824,11 +3873,11 @@ export class WorkspaceCollector {
3824
3873
  * message) as a "session.model" event and remembers it for the envelope's
3825
3874
  * "session" block. Identical for omnirush.ai and every external provider.
3826
3875
  */
3827
- recordSessionModel(sessionId: string, model: CollectorSessionModel): void {
3876
+ recordSessionModel(sessionId: string, model: SyncSessionModel): void {
3828
3877
  const state = this.sessions.get(sessionId);
3829
3878
  if (!state || state.finished) return;
3830
- const clamp = (value: string | null) => (value ? clampCollectorText(value, MAX_ENVIRONMENT_FIELD_CHARS) : null);
3831
- const cleaned: CollectorSessionModel = {
3879
+ const clamp = (value: string | null) => (value ? clampSyncText(value, MAX_ENVIRONMENT_FIELD_CHARS) : null);
3880
+ const cleaned: SyncSessionModel = {
3832
3881
  provider_id: clamp(model.provider_id),
3833
3882
  model_id: clamp(model.model_id),
3834
3883
  variant: clamp(model.variant),
@@ -3860,7 +3909,7 @@ export class WorkspaceCollector {
3860
3909
  * once the root turn settled: one "session.child" event carrying the child's
3861
3910
  * new messages, plus the child's checkpoint for the next capture.
3862
3911
  */
3863
- recordChildSession(sessionId: string, child: CollectorChildSession): void {
3912
+ recordChildSession(sessionId: string, child: SyncChildSession): void {
3864
3913
  const state = this.sessions.get(sessionId);
3865
3914
  if (!state || state.finished) return;
3866
3915
  if (!/^[A-Za-z0-9._:-]{1,256}$/.test(child.childSessionId)) return;
@@ -3873,9 +3922,9 @@ export class WorkspaceCollector {
3873
3922
  this.appendTrace(state, "session.child", {
3874
3923
  child_session_id: child.childSessionId,
3875
3924
  parent_session_id: child.parentSessionId,
3876
- ...(Number.isSafeInteger(child.depth) && child.depth! >= 1 && child.depth! <= MAX_COLLECTOR_CHILD_SESSION_DEPTH ? { depth: child.depth } : {}),
3877
- title: child.title ? clampCollectorText(child.title, MAX_GIT_SUBJECT_CHARS) : null,
3878
- agent: child.agent ? clampCollectorText(child.agent, MAX_ENVIRONMENT_FIELD_CHARS) : null,
3925
+ ...(Number.isSafeInteger(child.depth) && child.depth! >= 1 && child.depth! <= MAX_SYNC_CHILD_SESSION_DEPTH ? { depth: child.depth } : {}),
3926
+ title: child.title ? clampSyncText(child.title, MAX_GIT_SUBJECT_CHARS) : null,
3927
+ agent: child.agent ? clampSyncText(child.agent, MAX_ENVIRONMENT_FIELD_CHARS) : null,
3879
3928
  messages: withSubagentFallbacks(child.messages, state.subagentFallbacks.get(child.childSessionId)),
3880
3929
  });
3881
3930
  void this.persistOnceReady(state);
@@ -3886,18 +3935,18 @@ export class WorkspaceCollector {
3886
3935
  * browser-internal URLs are never traced; page text is redacted and capped.
3887
3936
  * Returns whether the visit was recorded.
3888
3937
  */
3889
- recordWebVisit(sessionId: string, visit: CollectorWebVisit): boolean {
3938
+ recordWebVisit(sessionId: string, visit: SyncWebVisit): boolean {
3890
3939
  const state = this.sessions.get(sessionId);
3891
3940
  if (!state || state.finished) return false;
3892
- if (typeof visit.url !== "string" || !isCollectableWebUrl(visit.url)) return false;
3941
+ if (typeof visit.url !== "string" || !isSyncableWebUrl(visit.url)) return false;
3893
3942
  const url = new URL(visit.url);
3894
3943
  url.username = "";
3895
3944
  url.password = "";
3896
- const title = collectorTextForTrace(visit.title ?? null, MAX_WEB_VISIT_TITLE_CHARS * 4);
3897
- const text = collectorTextForTrace(visit.text ?? null, MAX_COLLECTOR_WEB_VISIT_TEXT_BYTES);
3945
+ const title = syncTextForTrace(visit.title ?? null, MAX_WEB_VISIT_TITLE_CHARS * 4);
3946
+ const text = syncTextForTrace(visit.text ?? null, MAX_SYNC_WEB_VISIT_TEXT_BYTES);
3898
3947
  this.appendTrace(state, "web.visit", {
3899
- url: clampCollectorText(redactCollectorText(url.toString()).text, MAX_WEB_VISIT_URL_CHARS),
3900
- title: title.text === null ? null : clampCollectorText(title.text, MAX_WEB_VISIT_TITLE_CHARS),
3948
+ url: clampSyncText(redactSyncText(url.toString()).text, MAX_WEB_VISIT_URL_CHARS),
3949
+ title: title.text === null ? null : clampSyncText(title.text, MAX_WEB_VISIT_TITLE_CHARS),
3901
3950
  text: text.text,
3902
3951
  text_truncated: text.truncated,
3903
3952
  });
@@ -3905,13 +3954,13 @@ export class WorkspaceCollector {
3905
3954
  }
3906
3955
 
3907
3956
  /** Records a file attached to a prompt: identity, size, and redacted, capped text when extractable. */
3908
- recordAttachment(sessionId: string, attachment: CollectorAttachment): void {
3957
+ recordAttachment(sessionId: string, attachment: SyncAttachment): void {
3909
3958
  const state = this.sessions.get(sessionId);
3910
3959
  if (!state || state.finished) return;
3911
- const text = collectorTextForTrace(attachment.text, MAX_COLLECTOR_ATTACHMENT_TEXT_BYTES);
3960
+ const text = syncTextForTrace(attachment.text, MAX_SYNC_ATTACHMENT_TEXT_BYTES);
3912
3961
  this.appendTrace(state, "attachment", {
3913
- name: clampCollectorText(redactCollectorText(basename(attachment.name || "attachment")).text, MAX_ATTACHMENT_NAME_CHARS),
3914
- mime: clampCollectorText(attachment.mime || "application/octet-stream", MAX_ATTACHMENT_MIME_CHARS),
3962
+ name: clampSyncText(redactSyncText(basename(attachment.name || "attachment")).text, MAX_ATTACHMENT_NAME_CHARS),
3963
+ mime: clampSyncText(attachment.mime || "application/octet-stream", MAX_ATTACHMENT_MIME_CHARS),
3915
3964
  bytes: Math.max(0, Math.floor(attachment.bytes)),
3916
3965
  sha256: attachment.sha256,
3917
3966
  text: text.text,
@@ -3931,7 +3980,7 @@ export class WorkspaceCollector {
3931
3980
  }
3932
3981
  }
3933
3982
 
3934
- private sessionBlock(state: SessionState): CollectorSessionBlock {
3983
+ private sessionBlock(state: SessionState): SyncSessionBlock {
3935
3984
  return {
3936
3985
  provider_id: state.model?.provider_id ?? null,
3937
3986
  model_id: state.model?.model_id ?? null,
@@ -3963,7 +4012,7 @@ export class WorkspaceCollector {
3963
4012
  if (emitted >= MAX_ARTIFACT_EVENTS_PER_TURN || stat.size > MAX_ARTIFACT_HASH_BYTES) continue;
3964
4013
  try {
3965
4014
  const sha256 = await sha256File(resolve(state.root, path));
3966
- this.appendTrace(state, "artifact", { path: collectorPathForUpload(path), sha256, bytes: stat.size });
4015
+ this.appendTrace(state, "artifact", { path: syncPathForUpload(path), sha256, bytes: stat.size });
3967
4016
  state.touchedPaths.add(path);
3968
4017
  emitted += 1;
3969
4018
  } catch {
@@ -4063,14 +4112,14 @@ export class WorkspaceCollector {
4063
4112
  private async turnDiffInput(state: SessionState, change: TurnChange, pool: ReadPool, ancestors: AncestorCache, texts: boolean): Promise<TurnDiffInput | null> {
4064
4113
  const { path, before, after, unseen } = change;
4065
4114
  const input = {
4066
- path: (after ?? before)?.uploadPath ?? collectorPathForUpload(path),
4115
+ path: (after ?? before)?.uploadPath ?? syncPathForUpload(path),
4067
4116
  before_sha256: before?.sha256 ?? null,
4068
4117
  after_sha256: after?.sha256 ?? null,
4069
4118
  before: null,
4070
4119
  after: null,
4071
4120
  };
4072
4121
  if (after) {
4073
- // Changed by the turn from a form the collector never saw: nothing to diff against.
4122
+ // Changed by the turn from a form the session uploader never saw: nothing to diff against.
4074
4123
  if (unseen) return { ...input, status: "no_base" };
4075
4124
  if (before && !(await this.bases.has(before.sha256))) return { ...input, status: "no_base" };
4076
4125
  if (!texts) return { ...input, status: before ? "modified" : "added" };
@@ -4091,7 +4140,7 @@ export class WorkspaceCollector {
4091
4140
  return base === null ? { ...input, status: "no_base" } : { ...input, status: "deleted", before: base };
4092
4141
  }
4093
4142
  if (!file.isFile() || file.isSymbolicLink()) return null;
4094
- if (file.size <= MAX_COLLECTOR_FILE_BYTES && !state.cache.get(path)?.binary && !(await startsBinary(absolute))) return null;
4143
+ if (file.size <= MAX_SYNC_FILE_BYTES && !state.cache.get(path)?.binary && !(await startsBinary(absolute))) return null;
4095
4144
  return { ...input, after_sha256: null, status: "skipped" };
4096
4145
  }
4097
4146
 
@@ -4339,7 +4388,7 @@ export class WorkspaceCollector {
4339
4388
  private queueChangedPath(state: SessionState, filename: string): void {
4340
4389
  if (state.finished) return;
4341
4390
  const path = workspaceRelativePath(state.root, filename);
4342
- if (!path || isCollectorPathDenied(path)) return;
4391
+ if (!path || isSyncPathDenied(path)) return;
4343
4392
  // Several events for one path before its capture starts collapse into one read.
4344
4393
  if (state.pendingJournal.has(path)) return;
4345
4394
  state.pendingJournal.add(path);
@@ -4378,7 +4427,7 @@ export class WorkspaceCollector {
4378
4427
  const file = await lstat(absolute);
4379
4428
  const written = writtenThisTurn(state.turnStartedAt, state.cache.get(path), file);
4380
4429
  if (file.isFile() && !file.isSymbolicLink() && file.size > MAX_CHANGE_JOURNAL_BYTES && written) state.turnSkipped.set(path, file.mtimeMs);
4381
- if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_COLLECTOR_FILE_BYTES) {
4430
+ if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_SYNC_FILE_BYTES) {
4382
4431
  entry = { path, at: new Date().toISOString(), status: "skipped" };
4383
4432
  } else if (file.size > MAX_CHANGE_JOURNAL_BYTES) {
4384
4433
  // Larger than the whole journal: it could only evict every other entry
@@ -4388,7 +4437,7 @@ export class WorkspaceCollector {
4388
4437
  } else {
4389
4438
  const buffer = await readFile(absolute);
4390
4439
  this.metrics.fileReads += 1;
4391
- if (buffer.length > MAX_COLLECTOR_FILE_BYTES || isBinary(buffer)) {
4440
+ if (buffer.length > MAX_SYNC_FILE_BYTES || isBinary(buffer)) {
4392
4441
  if (written) state.turnSkipped.set(path, file.mtimeMs);
4393
4442
  entry = { path, at: new Date().toISOString(), status: "skipped" };
4394
4443
  } else {
@@ -4426,7 +4475,7 @@ export class WorkspaceCollector {
4426
4475
 
4427
4476
  private enqueue(state: SessionState, operation: () => Promise<void>): void {
4428
4477
  state.tail = state.tail.then(operation).catch((error: unknown) => {
4429
- this.log("warn", "OmniRush collection operation failed", {
4478
+ this.log("warn", "OmniRush session upload: operation failed", {
4430
4479
  sessionId: state.id,
4431
4480
  error: error instanceof Error ? error.message : "unknown",
4432
4481
  });
@@ -4434,7 +4483,7 @@ export class WorkspaceCollector {
4434
4483
  }
4435
4484
 
4436
4485
  private touchedPathsForUpload(state: SessionState): string[] {
4437
- return [...state.touchedPaths].map(collectorPathForUpload);
4486
+ return [...state.touchedPaths].map(syncPathForUpload);
4438
4487
  }
4439
4488
 
4440
4489
  /**
@@ -4448,7 +4497,7 @@ export class WorkspaceCollector {
4448
4497
  * and covers the whole tree otherwise. Resolves with whether a snapshot went
4449
4498
  * out.
4450
4499
  */
4451
- private async uploadWorkspace(state: SessionState, type: Exclude<SnapshotType, "trace">, trigger: CollectorTrigger): Promise<boolean> {
4500
+ private async uploadWorkspace(state: SessionState, type: Exclude<SnapshotType, "trace">, trigger: SyncTrigger): Promise<boolean> {
4452
4501
  const cache = state.cache;
4453
4502
  // Paths reported from here on belong to the next capture.
4454
4503
  const dirty = state.dirty;
@@ -4462,7 +4511,7 @@ export class WorkspaceCollector {
4462
4511
  // listing vouches for (the paths a targeted scan inspects, and every
4463
4512
  // journal entry), in one batch: nothing git ignores now leaves the
4464
4513
  // machine, whatever it answered when the event arrived.
4465
- const reported = targeted && previous !== null ? [...dirty].filter((path) => !isCollectorPathDenied(path)) : [];
4514
+ const reported = targeted && previous !== null ? [...dirty].filter((path) => !isSyncPathDenied(path)) : [];
4466
4515
  const journaled = type === "change" || type === "end" ? [...state.changeJournal.values()] : [];
4467
4516
  const checked = [...new Set([...reported, ...journaled.map((entry) => entry.path)])];
4468
4517
  const ignored = checked.length > 0 ? await this.ignoredPaths(state, checked, true) : new Set<string>();
@@ -4482,20 +4531,20 @@ export class WorkspaceCollector {
4482
4531
  if (previous !== null) for (const path of scan.changed ?? []) this.reportTouched(state, path);
4483
4532
  // A change capture that found nothing moved stops at one `git rev-parse`
4484
4533
  // (a commit changes history without touching a file); the full git block
4485
- // with its status, log and diff is collected only for a snapshot that goes out.
4534
+ // with its status, log and diff is read only for a snapshot that goes out.
4486
4535
  if (type === "change" && scan.changed !== null && scan.changed.size === 0 && scan.removed === 0 && !this.journalHasChanges(state)
4487
4536
  && (await gitHead(state.root)) === state.lastHead) {
4488
4537
  accepted = true;
4489
4538
  return false;
4490
4539
  }
4491
4540
  // Its status and diff name the files a capped snapshot must keep.
4492
- const git = await collectGitBlock(state.root);
4541
+ const git = await readGitBlock(state.root);
4493
4542
  const candidates = candidatesFor(scan, snapshotPriorityPaths(state.touchedPaths, git));
4494
4543
  const environment = await this.environment();
4495
4544
  const rootName = workspaceRootName(state.root);
4496
4545
  const touchedPaths = this.touchedPathsForUpload(state);
4497
4546
  const metadata = JSON.stringify({
4498
- schema_version: COLLECTOR_SCHEMA_VERSION,
4547
+ schema_version: SYNC_SCHEMA_VERSION,
4499
4548
  workspace_id: state.workspaceId,
4500
4549
  session_id: state.id,
4501
4550
  session_segment: state.segment,
@@ -4507,12 +4556,12 @@ export class WorkspaceCollector {
4507
4556
  root_name: rootName,
4508
4557
  git: { commit: git?.commit ?? null, branch: git?.branch ?? null, dirty: git ? String(git.dirty) : "false" },
4509
4558
  });
4510
- const leading: CollectorFile[] = [{ path: "__omnirush__/workspace.json", content: metadata, sha256: sha256Hex(metadata) }];
4559
+ const leading: SyncFile[] = [{ path: "__omnirush__/workspace.json", content: metadata, sha256: sha256Hex(metadata) }];
4511
4560
  if (journal.length > 0) {
4512
4561
  const changes = JSON.stringify({
4513
4562
  schema_version: 1,
4514
4563
  session_id: state.id,
4515
- entries: journal.map((entry) => ({ ...entry, path: collectorPathForUpload(entry.path) })),
4564
+ entries: journal.map((entry) => ({ ...entry, path: syncPathForUpload(entry.path) })),
4516
4565
  });
4517
4566
  leading.push({ path: "__omnirush__/changes.json", content: changes, sha256: sha256Hex(changes) });
4518
4567
  }
@@ -4587,7 +4636,7 @@ export class WorkspaceCollector {
4587
4636
  omitted_bytes: omittedBytes,
4588
4637
  budget_bytes: budget,
4589
4638
  });
4590
- this.log("warn", "OmniRush collection snapshot trimmed to the snapshot cap", {
4639
+ this.log("warn", "OmniRush session upload: snapshot trimmed to the snapshot cap", {
4591
4640
  sessionId: state.id,
4592
4641
  snapshotType: type,
4593
4642
  trigger,
@@ -4637,7 +4686,7 @@ export class WorkspaceCollector {
4637
4686
 
4638
4687
  /**
4639
4688
  * One POST of an envelope, ended by `deadline` (the attempt's size-scaled
4640
- * deadline, collect-upload-budget.ts: fetch reports no upload progress, so
4689
+ * deadline, sync-upload-budget.ts: fetch reports no upload progress, so
4641
4690
  * sending the body and the gateway's answer share one deadline) or sooner
4642
4691
  * by `cancel`.
4643
4692
  */
@@ -4645,7 +4694,7 @@ export class WorkspaceCollector {
4645
4694
  const signal = cancel ? AbortSignal.any([deadline, cancel]) : deadline;
4646
4695
  if (signal.aborted) return Promise.reject(signal.reason);
4647
4696
  if (this.uploader) return untilAborted(this.uploader(sessionId, compressed, signal), signal);
4648
- return this.fetcher(this.collectUrl!, {
4697
+ return this.fetcher(this.syncUrl!, {
4649
4698
  method: "POST",
4650
4699
  headers: {
4651
4700
  Authorization: `Bearer ${this.token}`,
@@ -4679,8 +4728,8 @@ export class WorkspaceCollector {
4679
4728
  * the spool at once and the spool drain retries it on its own backoff.
4680
4729
  */
4681
4730
  private async transmit(sessionId: string, compressed: Uint8Array, attempts = UPLOAD_ATTEMPTS, cancel?: AbortSignal): Promise<TransmitOutcome> {
4682
- const timeoutMs = collectUploadTimeoutMs(compressed.byteLength, this.uploadBudget);
4683
- let lastReason = "collector upload unavailable";
4731
+ const timeoutMs = syncUploadTimeoutMs(compressed.byteLength, this.uploadBudget);
4732
+ let lastReason = "session upload unavailable";
4684
4733
  let refreshAttempted = false;
4685
4734
  let attempt = 0;
4686
4735
  while (attempt < attempts) {
@@ -4689,7 +4738,7 @@ export class WorkspaceCollector {
4689
4738
  try {
4690
4739
  const response = await this.send(sessionId, compressed, deadline, cancel);
4691
4740
  if (response.ok) return { ok: true };
4692
- lastReason = `collector upload failed with status ${response.status}`;
4741
+ lastReason = `session upload failed with status ${response.status}`;
4693
4742
  if (UNAUTHORIZED_STATUSES.has(response.status)) {
4694
4743
  // The sign-in gate is final: nothing is queued for an account that
4695
4744
  // is gone. Any other rejection is a device token the gateway broker
@@ -4701,7 +4750,7 @@ export class WorkspaceCollector {
4701
4750
  if (refreshAttempted) return { ok: false, retryable: true, reason: lastReason };
4702
4751
  refreshAttempted = true;
4703
4752
  const refresh = await this.refreshToken();
4704
- this.log("warn", "OmniRush collection upload rejected as unauthorized", {
4753
+ this.log("warn", "OmniRush session upload: upload rejected as unauthorized", {
4705
4754
  sessionId,
4706
4755
  status: response.status,
4707
4756
  refreshed: refresh.refreshed,
@@ -4714,7 +4763,7 @@ export class WorkspaceCollector {
4714
4763
  await response.body?.cancel().catch(() => undefined);
4715
4764
  if (!RETRYABLE_STATUSES.has(response.status)) return { ok: false, retryable: false, reason: lastReason };
4716
4765
  } catch (error) {
4717
- lastReason = error instanceof Error ? error.message : "collector upload unavailable";
4766
+ lastReason = error instanceof Error ? error.message : "session upload unavailable";
4718
4767
  if (cancel?.aborted) return { ok: false, retryable: true, reason: lastReason };
4719
4768
  // Out of time: this attempt's deadline, or the gateway broker's own
4720
4769
  // (the same size-scaled budget, reported across the worker boundary by name).
@@ -4761,8 +4810,8 @@ export class WorkspaceCollector {
4761
4810
  * retryable failure moves the file into the spool instead. Resolves with
4762
4811
  * whether the envelope was accepted (delivered or spooled).
4763
4812
  */
4764
- private async uploadEnvelope(state: SessionState, snapshotType: SnapshotType, trigger: CollectorTrigger, body: EnvelopeBody): Promise<boolean> {
4765
- if (!this.collectUrl && !this.uploader) return false;
4813
+ private async uploadEnvelope(state: SessionState, snapshotType: SnapshotType, trigger: SyncTrigger, body: EnvelopeBody): Promise<boolean> {
4814
+ if (!this.syncUrl && !this.uploader) return false;
4766
4815
  const sequence = state.sequence + 1;
4767
4816
  const path = await this.envelopeTempPath();
4768
4817
  const writer = new EnvelopeWriter(path);
@@ -4770,7 +4819,7 @@ export class WorkspaceCollector {
4770
4819
  let compressedBytes: number;
4771
4820
  try {
4772
4821
  const head = JSON.stringify({
4773
- schema_version: COLLECTOR_SCHEMA_VERSION,
4822
+ schema_version: SYNC_SCHEMA_VERSION,
4774
4823
  session_id: state.id,
4775
4824
  session_segment: state.segment,
4776
4825
  session_resumed: state.resumed,
@@ -4819,7 +4868,7 @@ export class WorkspaceCollector {
4819
4868
  // retried. The scan budget keeps this from happening; reaching it means
4820
4869
  // the wrapper outgrew its margin, which deserves a loud line here rather
4821
4870
  // than a lost upload and a rejection upstream.
4822
- this.log("warn", "OmniRush collection payload exceeded the snapshot cap", {
4871
+ this.log("warn", "OmniRush session upload: payload exceeded the snapshot cap", {
4823
4872
  sessionId: state.id,
4824
4873
  snapshotType,
4825
4874
  trigger,
@@ -4829,7 +4878,7 @@ export class WorkspaceCollector {
4829
4878
  return false;
4830
4879
  }
4831
4880
  if (compressedBytes > MAX_COMPRESSED_BYTES) {
4832
- this.log("warn", "OmniRush collection payload exceeded compressed limit", { sessionId: state.id, snapshotType, trigger });
4881
+ this.log("warn", "OmniRush session upload: payload exceeded compressed limit", { sessionId: state.id, snapshotType, trigger });
4833
4882
  return false;
4834
4883
  }
4835
4884
  const compressed = await readFile(path);
@@ -4843,7 +4892,7 @@ export class WorkspaceCollector {
4843
4892
  const spooled = await this.spoolEnvelopeFile({ id: "", session_id: state.id, snapshot_type: snapshotType, trigger, sequence, bytes: compressedBytes, created_at: new Date().toISOString(), attempts: 1 }, path, account);
4844
4893
  if (!spooled) {
4845
4894
  // Signed out during the upload: the envelope is deleted, never queued for the next account.
4846
- this.log("info", "OmniRush collection artifact discarded at sign-out", { sessionId: state.id, snapshotType, trigger, sequence });
4895
+ this.log("info", "OmniRush session upload: artifact discarded at sign-out", { sessionId: state.id, snapshotType, trigger, sequence });
4847
4896
  return false;
4848
4897
  }
4849
4898
  state.sentBytes += bytes;
@@ -4851,7 +4900,7 @@ export class WorkspaceCollector {
4851
4900
  state.failureCount += 1;
4852
4901
  state.lastFailureAt = new Date().toISOString();
4853
4902
  await this.persistSession(state).catch(() => undefined);
4854
- this.log("warn", "OmniRush collection artifact spooled for retry", {
4903
+ this.log("warn", "OmniRush session upload: artifact spooled for retry", {
4855
4904
  sessionId: state.id,
4856
4905
  snapshotType,
4857
4906
  trigger,
@@ -4872,7 +4921,7 @@ export class WorkspaceCollector {
4872
4921
  error: error instanceof Error ? error.message : "unknown",
4873
4922
  });
4874
4923
  });
4875
- this.log("info", "OmniRush collection artifact uploaded", {
4924
+ this.log("info", "OmniRush session upload: artifact uploaded", {
4876
4925
  sessionId: state.id,
4877
4926
  snapshotType,
4878
4927
  trigger,
@@ -4965,7 +5014,7 @@ export class WorkspaceCollector {
4965
5014
  await this.removeSpoolEntry(entry.id);
4966
5015
  total -= entry.bytes;
4967
5016
  count -= 1;
4968
- this.log("warn", "OmniRush collection spool dropped its oldest entry", {
5017
+ this.log("warn", "OmniRush session upload: spool dropped its oldest entry", {
4969
5018
  sessionId: entry.session_id,
4970
5019
  snapshotType: entry.snapshot_type,
4971
5020
  sequence: entry.sequence,
@@ -5065,7 +5114,7 @@ export class WorkspaceCollector {
5065
5114
  this.retryFailures = 0;
5066
5115
  delivered += 1;
5067
5116
  pending -= 1;
5068
- this.log("info", "OmniRush collection artifact delivered from spool", {
5117
+ this.log("info", "OmniRush session upload: artifact delivered from spool", {
5069
5118
  sessionId: entry.session_id,
5070
5119
  snapshotType: entry.snapshot_type,
5071
5120
  trigger: entry.trigger,
@@ -5079,7 +5128,7 @@ export class WorkspaceCollector {
5079
5128
  if (!outcome.retryable || entry.attempts + 1 >= MAX_SPOOL_ATTEMPTS) {
5080
5129
  await this.spoolLocked(() => this.removeSpoolEntry(entry.id));
5081
5130
  pending -= 1;
5082
- this.log("warn", "OmniRush collection artifact dropped from spool", {
5131
+ this.log("warn", "OmniRush session upload: artifact dropped from spool", {
5083
5132
  sessionId: entry.session_id,
5084
5133
  snapshotType: entry.snapshot_type,
5085
5134
  sequence: entry.sequence,