@outerlayer/cli 0.1.1 → 0.4.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 (143) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +238 -77
  3. package/dist/agent-setup-IFH3FB5X.js +197 -0
  4. package/dist/build-TZUUTRGH.js +9 -0
  5. package/dist/build-info.json +1 -1
  6. package/dist/check-WQP2CZDW.js +94 -0
  7. package/dist/chunk-2E54Z3P5.js +232 -0
  8. package/dist/chunk-3TT7RQYB.js +55 -0
  9. package/dist/chunk-3YPKXE5L.js +1683 -0
  10. package/dist/{chunk-HNCCD2IX.js → chunk-5DLBAXY5.js} +209 -61
  11. package/dist/{chunk-R5KBGVII.js → chunk-5QRP3MVS.js} +30 -14
  12. package/dist/chunk-5Y3QQRUH.js +300 -0
  13. package/dist/chunk-6Y63SXFO.js +383 -0
  14. package/dist/chunk-774HEQL6.js +178 -0
  15. package/dist/chunk-7A6RVXPZ.js +2233 -0
  16. package/dist/chunk-7YIOOFVJ.js +71 -0
  17. package/dist/chunk-ABIWDUMS.js +104 -0
  18. package/dist/chunk-AUFG23AA.js +534 -0
  19. package/dist/chunk-B2G7JHHB.js +42 -0
  20. package/dist/chunk-BCJNHMZT.js +34 -0
  21. package/dist/chunk-BCLJSQCV.js +56 -0
  22. package/dist/chunk-BFESLKPP.js +61 -0
  23. package/dist/chunk-BJ3KTMDH.js +63 -0
  24. package/dist/chunk-BKO6JEZI.js +47 -0
  25. package/dist/chunk-BOLTI6LR.js +37 -0
  26. package/dist/chunk-CBPPJSAR.js +89 -0
  27. package/dist/chunk-CBZB6XY6.js +102 -0
  28. package/dist/{chunk-RCQXYLMO.js → chunk-DVDEBNQJ.js} +18 -18
  29. package/dist/{work-pr-cmd-GOYSPEN2.js → chunk-DVYHCMB2.js} +10 -25
  30. package/dist/{chunk-TFUIDMOB.js → chunk-EABW6AJQ.js} +12 -1
  31. package/dist/{chunk-KFYJV2ZG.js → chunk-EMY4I27X.js} +1 -1
  32. package/dist/{chunk-NTTPJV35.js → chunk-F47JBIAW.js} +3 -1
  33. package/dist/chunk-F6GFZXZ3.js +60 -0
  34. package/dist/{chunk-YOOSBOKS.js → chunk-F7CU5ABH.js} +1 -1
  35. package/dist/chunk-FAJSS2WN.js +1357 -0
  36. package/dist/chunk-FS2VYGZT.js +1764 -0
  37. package/dist/chunk-FV2CCGXK.js +9 -0
  38. package/dist/{chunk-BQB3U2VD.js → chunk-G7FSV7JX.js} +5113 -3729
  39. package/dist/{emit-cmd-TWSYYEZI.js → chunk-H5NCNVDA.js} +68 -17
  40. package/dist/chunk-HE2D4EY5.js +83 -0
  41. package/dist/chunk-L2DBRKRA.js +94 -0
  42. package/dist/chunk-LF6MLJCY.js +38 -0
  43. package/dist/chunk-M5POMKKW.js +1051 -0
  44. package/dist/{mcp-install-cmd-FDQH6SEN.js → chunk-MQ3IPHIZ.js} +35 -18
  45. package/dist/{chunk-D77LS3UI.js → chunk-N5FOE5PS.js} +33 -4
  46. package/dist/chunk-N6LURUEF.js +64 -0
  47. package/dist/chunk-NYC54VBD.js +1084 -0
  48. package/dist/chunk-OGFGZQA3.js +23 -0
  49. package/dist/chunk-QOZKPLVJ.js +226 -0
  50. package/dist/chunk-RA3O54FC.js +30 -0
  51. package/dist/chunk-TBT347UY.js +41 -0
  52. package/dist/{chunk-3VQ4XXQA.js → chunk-UFFXNXLQ.js} +115 -71
  53. package/dist/chunk-USO2DKBD.js +421 -0
  54. package/dist/chunk-W4SQCOZU.js +206 -0
  55. package/dist/{chunk-A3WLZX2F.js → chunk-WIOZAJ2W.js} +15 -2
  56. package/dist/chunk-WO2BXCTQ.js +83 -0
  57. package/dist/chunk-WZBHBH3J.js +20 -0
  58. package/dist/{chunk-IWIUYLDR.js → chunk-XCCLFVXM.js} +118 -189
  59. package/dist/{context-materialize-J6CRQ7O7.js → chunk-XDDW4FRS.js} +134 -26
  60. package/dist/chunk-YYYXRJUW.js +101 -0
  61. package/dist/chunk-ZM2IMMYN.js +76 -0
  62. package/dist/chunk-ZMYPLFG3.js +71 -0
  63. package/dist/chunk-ZNA27WEV.js +470 -0
  64. package/dist/{cli-EFJIG2VT.js → cli-NHUXABYH.js} +597 -965
  65. package/dist/{paths-D2VGWWFI.js → cli-build-K56DK4DS.js} +1 -1
  66. package/dist/config-XVJYZ4IQ.js +9 -0
  67. package/dist/connect-cmd-OOG4TPU7.js +16 -0
  68. package/dist/context-adopt-UMA4O5NY.js +105 -0
  69. package/dist/context-materialize-G2FUFC72.js +19 -0
  70. package/dist/dist-RW4UPPC2.js +6 -0
  71. package/dist/docker-NEGDLU6D.js +7 -0
  72. package/dist/doctor-53F2PYY7.js +43 -0
  73. package/dist/doctor-UFVL4PZY.js +426 -0
  74. package/dist/{emit-artifact-cmd-KPPD3YLO.js → emit-artifact-cmd-KTYW2TN2.js} +25 -19
  75. package/dist/emit-cmd-DCERAU27.js +9 -0
  76. package/dist/emit-criteria-cmd-VAUUSAYD.js +166 -0
  77. package/dist/{emit-finding-cmd-VR5VEGXJ.js → emit-finding-cmd-UR4ID45Z.js} +35 -30
  78. package/dist/{emit-result-cmd-YOA5BJKL.js → emit-result-cmd-FDTUKGKE.js} +35 -61
  79. package/dist/exec-client-4XGXV3XN.js +8 -0
  80. package/dist/guest-init-GENRHNP7.js +8 -0
  81. package/dist/hook-fast-EYENPK6F.js +9 -0
  82. package/dist/{hook-wrap-fast-KXYNX3AD.js → hook-wrap-fast-NPLHJXFK.js} +2 -2
  83. package/dist/host-key-KDYZ5ECC.js +7 -0
  84. package/dist/import-capture-cmd-AVZ4VIVJ.js +68 -0
  85. package/dist/{import-ruler-cmd-7LH2QOMG.js → import-ruler-cmd-GUHL6IY5.js} +1 -1
  86. package/dist/index.js +4 -4
  87. package/dist/init-DXBCOAJK.js +36 -0
  88. package/dist/init-YEXK35CF.js +221 -0
  89. package/dist/init-cmd-NHR7PRSQ.js +135 -0
  90. package/dist/install-cmd-7WXOIFDO.js +55 -0
  91. package/dist/lima-XN65D7GN.js +55 -0
  92. package/dist/login-browser-7XGSV7MA.js +10 -0
  93. package/dist/{config-POF7DEQW.js → logout-cmd-QAUFDJ6J.js} +3 -1
  94. package/dist/{logs-TRNPQM42.js → logs-7RYUH5A6.js} +1 -1
  95. package/dist/loop-VZOLL4WQ.js +43 -0
  96. package/dist/machine-WSG52J75.js +35 -0
  97. package/dist/mcp-install-cmd-RLK4SO3N.js +9 -0
  98. package/dist/{mcp-serve-cmd-57EZZOTL.js → mcp-serve-cmd-JPTP5FMS.js} +20 -9
  99. package/dist/paths-OYKMVYJP.js +6 -0
  100. package/dist/{pidfile-PTW76F56.js → pidfile-UZRH774M.js} +2 -3
  101. package/dist/real-deps-SU24ZA2K.js +21 -0
  102. package/dist/relay-L76HDX72.js +46 -0
  103. package/dist/settings-J2652U5N.js +7 -0
  104. package/dist/starter-pack-LIZYMKYQ.js +10 -0
  105. package/dist/{status-JHVSZYC5.js → status-2UZKKNB7.js} +31 -12
  106. package/dist/{statusline-fast-3C5OXHDD.js → statusline-fast-SVTMBMD7.js} +3 -2
  107. package/dist/sync-cmd-VPPYCNNM.js +28 -0
  108. package/dist/version-BWM6VLDI.js +6 -0
  109. package/dist/{watch-NRLXJUST.js → watch-5C4ZBBGO.js} +20 -7
  110. package/dist/{work-claim-cmd-HXB7K27H.js → work-claim-cmd-LSO2B2EX.js} +34 -19
  111. package/dist/work-cmd-UOQO2AD4.js +17 -0
  112. package/dist/work-comment-cmd-WF3EOLAE.js +106 -0
  113. package/dist/{work-launch-YNNCKIH3.js → work-launch-YI4CJDAP.js} +2 -1
  114. package/dist/work-open-pr-cmd-4SSQZMYG.js +156 -0
  115. package/dist/work-pr-cmd-6AN6RRQJ.js +16 -0
  116. package/package.json +13 -3
  117. package/skill-pack/maintained/amend/SKILL.md +104 -0
  118. package/skill-pack/maintained/emitting-evidence/SKILL.md +99 -0
  119. package/skill-pack/maintained/emitting-evidence/references/agents-snippet.md +20 -0
  120. package/skill-pack/maintained/outerlayer/SKILL.md +55 -0
  121. package/skill-pack/maintained/reporting-findings/SKILL.md +148 -0
  122. package/skill-pack/template/build/SKILL.md +193 -0
  123. package/skill-pack/template/build/references/agent-briefs.md +243 -0
  124. package/skill-pack/template/build/references/criteria-judge.md +91 -0
  125. package/skill-pack/template/build/references/evidence.md +42 -0
  126. package/skill-pack/template/build/references/release.md +74 -0
  127. package/skill-pack/template/build/references/review-briefs.md +275 -0
  128. package/skill-pack/template/build/references/review-loop.md +158 -0
  129. package/skill-pack/template/build/scripts/record-criteria.mjs +235 -0
  130. package/skill-pack/template/spec/SKILL.md +84 -0
  131. package/skill-pack/template/writing-specs/SKILL.md +134 -0
  132. package/dist/chunk-JJP7YLMN.js +0 -25
  133. package/dist/chunk-KKEU6FFR.js +0 -589
  134. package/dist/chunk-OZ7C3XUE.js +0 -34
  135. package/dist/chunk-WQ6VGRGZ.js +0 -150
  136. package/dist/emit-commit-credit-cmd-656BRAQ4.js +0 -121
  137. package/dist/hook-fast-I2XTHDE6.js +0 -8
  138. package/dist/import-capture-cmd-EUMBGIV3.js +0 -176
  139. package/dist/init-PTBITAUO.js +0 -103
  140. package/dist/login-cmd-IRX6LZT7.js +0 -62
  141. package/dist/loop-BWE2BS6J.js +0 -646
  142. package/dist/sync-cmd-S7Q6DWU3.js +0 -15
  143. package/dist/work-cmd-ABP6XDBP.js +0 -16
@@ -0,0 +1,1683 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { applyContextProvenance, installScrubContext, prepareSessionForUpload, wholeSessionCounts, sliceTurnIo } from './chunk-HE2D4EY5.js';
4
+ import { LaunchFilter } from './chunk-BCJNHMZT.js';
5
+ import { RepoFilter } from './chunk-TBT347UY.js';
6
+ import { hookExecSpoolPath } from './chunk-F7CU5ABH.js';
7
+ import { applyReportedCost } from './chunk-BKO6JEZI.js';
8
+ import { migrateLegacyArtifactSidecars, planArtifactUpload, artifactsStatePath, sanitizeTerminal, artifactBlobsDir, resolveArtifactTurnIndex, isArtifactTombstoned, ARTIFACT_RETRY_MAX_AGE_MS, writeArtifactSpoolState, reclaimUnneededArtifactBlobs, neededArtifactShas } from './chunk-6Y63SXFO.js';
9
+ import { tailTranscript, enrichSessionRepo, CAPTURE_TIERS, scanAll, setRepoRoot, resolveRepoIdentity, inferArtifactKind, bannedPathsForTier, sessionIdFromPath, SOURCE_ADAPTERS, EVENT_TYPES } from './chunk-G7FSV7JX.js';
10
+ import { C } from './chunk-YRYMNBGQ.js';
11
+ import { readLaunchSpoolState, launchStatusIsDecisive, launchedItemsBySession, launchesPath } from './chunk-DVDEBNQJ.js';
12
+ import { readLatestProvenanceForSession, readContextProvenance } from './chunk-ZJSNLVCE.js';
13
+ import { defaultFetch } from './chunk-BCLJSQCV.js';
14
+ import { resolveGatewayCredentials, unauthorizedMessage } from './chunk-BOLTI6LR.js';
15
+ import { readCloudConfig, cloudConfigPath, writeCloudConfig } from './chunk-EABW6AJQ.js';
16
+ import { signerFor, signRunnerRequest } from './chunk-M5POMKKW.js';
17
+ import { writeSyncStatus, spoolDir } from './chunk-UFFXNXLQ.js';
18
+ import { removeJournalHooks } from './chunk-XCCLFVXM.js';
19
+ import { readRepoConnection, findGitDirs } from './chunk-BJ3KTMDH.js';
20
+ import { homeFromEnv } from './chunk-VNVZDWO3.js';
21
+ import { dirname, basename, join } from 'path';
22
+ import { statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync, existsSync, rmSync } from 'fs';
23
+ import { fileURLToPath } from 'url';
24
+ import { createRequire as createRequire$1 } from 'module';
25
+
26
+ createRequire(import.meta.url);
27
+ var IRRELEVANT = /* @__PURE__ */ new Set(["__tests__", "__mocks__", "node_modules"]);
28
+ function newestSourceMs(dir) {
29
+ let newest = 0;
30
+ let entries;
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true });
33
+ } catch {
34
+ return 0;
35
+ }
36
+ for (const entry of entries) {
37
+ if (entry.isDirectory()) {
38
+ if (IRRELEVANT.has(entry.name)) continue;
39
+ newest = Math.max(newest, newestSourceMs(join(dir, entry.name)));
40
+ continue;
41
+ }
42
+ if (!entry.name.endsWith(".ts")) continue;
43
+ if (entry.name.endsWith(".test.ts")) continue;
44
+ try {
45
+ newest = Math.max(newest, statSync(join(dir, entry.name)).mtimeMs);
46
+ } catch {
47
+ }
48
+ }
49
+ return newest;
50
+ }
51
+ function staleBuildWarning(entryPath = fileURLToPath(import.meta.url)) {
52
+ const distDir = dirname(entryPath);
53
+ if (basename(distDir) !== "dist") return null;
54
+ const srcDir = join(dirname(distDir), "src");
55
+ const sourceMs = newestSourceMs(srcDir);
56
+ if (sourceMs === 0) return null;
57
+ let builtMs;
58
+ try {
59
+ builtMs = statSync(entryPath).mtimeMs;
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (sourceMs <= builtMs) return null;
64
+ const age = Math.round((sourceMs - builtMs) / 1e3);
65
+ const howOld = age >= 86400 ? `${Math.round(age / 86400)}d` : age >= 3600 ? `${Math.round(age / 3600)}h` : `${age}s`;
66
+ return `this CLI build is ${howOld} older than its sources \u2014 run \`yarn workspace @outerlayer/cli build\`. Until you do, it may write data the current format cannot read.`;
67
+ }
68
+ var ALLOW_STALE_BUILD_ENV = "OUTERLAYER_ALLOW_STALE_BUILD";
69
+ function staleBuildRefusal(env = process.env, entryPath = fileURLToPath(import.meta.url)) {
70
+ const warning = staleBuildWarning(entryPath);
71
+ if (!warning) return null;
72
+ if (env[ALLOW_STALE_BUILD_ENV] === "1") return null;
73
+ if (env.CI === "true" || env.CI === "1" || env.JENKINS_URL) return null;
74
+ return `${warning.replace(/ Until you do,.*$/, "")} Refusing to write with it; set ${ALLOW_STALE_BUILD_ENV}=1 to write anyway.`;
75
+ }
76
+ function watermarkPath(home = homeFromEnv()) {
77
+ return join(home, ".outerlayer", "watermarks.json");
78
+ }
79
+ function watermarkKeyFor(url, appId) {
80
+ return `${new URL(url).host}:${appId}`;
81
+ }
82
+ function readFile(home) {
83
+ try {
84
+ const parsed = JSON.parse(readFileSync(watermarkPath(home), "utf8"));
85
+ if (!parsed || typeof parsed !== "object") return {};
86
+ const out = {};
87
+ for (const [k, v] of Object.entries(parsed)) {
88
+ if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
89
+ }
90
+ return out;
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+ function readWatermark(url, appId, home = homeFromEnv(), migrateFn = readSqliteWatermark) {
96
+ const key = watermarkKeyFor(url, appId);
97
+ const file = readFile(home);
98
+ if (key in file) return file[key];
99
+ const migrated = migrateFn(url, appId, home);
100
+ writeWatermark(url, appId, migrated ?? 0, home);
101
+ return migrated ?? 0;
102
+ }
103
+ function writeWatermark(url, appId, mtimeMs, home = homeFromEnv()) {
104
+ try {
105
+ const key = watermarkKeyFor(url, appId);
106
+ const file = readFile(home);
107
+ file[key] = mtimeMs;
108
+ const path = watermarkPath(home);
109
+ mkdirSync(join(home, ".outerlayer"), { recursive: true });
110
+ const tmp = `${path}.tmp`;
111
+ writeFileSync(tmp, JSON.stringify(file, null, 2));
112
+ renameSync(tmp, path);
113
+ } catch {
114
+ }
115
+ }
116
+ function readSqliteWatermark(url, appId, home) {
117
+ try {
118
+ const dbPath = join(home, ".outerlayer", "db.sqlite");
119
+ if (!existsSync(dbPath)) return null;
120
+ const { DatabaseSync } = createRequire$1(import.meta.url)("node:sqlite");
121
+ const db = new DatabaseSync(dbPath, { readOnly: true });
122
+ try {
123
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(`sync_watermark_ms:${new URL(url).host}:${appId}`);
124
+ if (!row) return null;
125
+ const ms = parseFloat(row.value);
126
+ return Number.isFinite(ms) ? ms : null;
127
+ } finally {
128
+ db.close();
129
+ }
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ var HOOK_TIMEOUT_MS = 6e5;
135
+ var HOOK_INCOMPLETE_SLACK_MS = 6e4;
136
+ var HOOK_INCOMPLETE_AFTER_MS = HOOK_TIMEOUT_MS + HOOK_INCOMPLETE_SLACK_MS;
137
+ function formatDurationApprox(ms) {
138
+ const totalSeconds = Math.floor(ms / 1e3);
139
+ if (totalSeconds < 60) return `${totalSeconds}s`;
140
+ const minutes = Math.floor(totalSeconds / 60);
141
+ const seconds = totalSeconds % 60;
142
+ return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
143
+ }
144
+ function deriveHookOutcome(group, opts) {
145
+ const started = group.started;
146
+ if (!started || !started.sessionId || !started.hookEvent) return void 0;
147
+ const base = {
148
+ sessionId: started.sessionId,
149
+ hookEvent: started.hookEvent,
150
+ toolUseId: started.toolUseId ?? void 0,
151
+ command: started.cmd,
152
+ ts: started.t
153
+ };
154
+ if (group.finished) {
155
+ const exitCode = group.finished.exitCode;
156
+ return {
157
+ ...base,
158
+ durationMs: group.finished.durationMs,
159
+ ...exitCode !== null ? { exitCode } : {},
160
+ status: exitCode === 0 ? "ok" : "error"
161
+ };
162
+ }
163
+ if (group.aborted) {
164
+ return {
165
+ ...base,
166
+ durationMs: group.aborted.durationMs,
167
+ status: "aborted"
168
+ };
169
+ }
170
+ const startedAtMs = Date.parse(started.t);
171
+ const ageMs = opts.nowMs - startedAtMs;
172
+ if (!opts.sessionEnded && ageMs < HOOK_INCOMPLETE_AFTER_MS) return "defer";
173
+ if (opts.spoolDamaged) return void 0;
174
+ return {
175
+ ...base,
176
+ durationMs: ageMs,
177
+ status: "incomplete",
178
+ statusMessage: `did not complete (killed or crashed) \u2014 ran \u2265 ${formatDurationApprox(ageMs)}`
179
+ };
180
+ }
181
+ function hookOutcomeToEvent(outcome) {
182
+ return {
183
+ type: EVENT_TYPES.hookExecuted,
184
+ ts: outcome.ts,
185
+ data: {
186
+ hookEvent: outcome.hookEvent,
187
+ hooks: [
188
+ {
189
+ command: outcome.command,
190
+ durationMs: outcome.durationMs,
191
+ ...outcome.exitCode !== void 0 ? { exitCode: outcome.exitCode } : {},
192
+ status: outcome.status,
193
+ ...outcome.toolUseId ? { toolUseId: outcome.toolUseId } : {},
194
+ ...outcome.statusMessage ? { statusMessage: outcome.statusMessage } : {}
195
+ }
196
+ ]
197
+ }
198
+ };
199
+ }
200
+ function appendHookExecEvents(session, events) {
201
+ let nextSeq = session.events.reduce((max, e) => Math.max(max, e.seq), -1) + 1;
202
+ for (const event of events) session.events.push({ ...event, seq: nextSeq++ });
203
+ }
204
+ function hookExecWatermarkPath(home = homeFromEnv()) {
205
+ return join(spoolWatermarkDir(home), "hook-exec.watermark");
206
+ }
207
+ function spoolWatermarkDir(home) {
208
+ return join(home, ".outerlayer", "spool");
209
+ }
210
+ function readHookExecWatermark(home = homeFromEnv()) {
211
+ try {
212
+ const raw = readFileSync(hookExecWatermarkPath(home), "utf8").trim();
213
+ const n = parseInt(raw, 10);
214
+ return Number.isFinite(n) && n >= 0 ? n : 0;
215
+ } catch {
216
+ return 0;
217
+ }
218
+ }
219
+ function writeHookExecWatermark(offset, home = homeFromEnv()) {
220
+ try {
221
+ const path = hookExecWatermarkPath(home);
222
+ mkdirSync(dirname(path), { recursive: true });
223
+ const tmp = `${path}.tmp`;
224
+ writeFileSync(tmp, String(offset));
225
+ renameSync(tmp, path);
226
+ } catch {
227
+ }
228
+ }
229
+ function readSpoolLinesSince(home, sinceOffset) {
230
+ let buf;
231
+ try {
232
+ buf = readFileSync(hookExecSpoolPath(home));
233
+ } catch {
234
+ return { lines: [], consumableEnd: sinceOffset, damagedLines: 0 };
235
+ }
236
+ const lines = [];
237
+ let damagedLines = 0;
238
+ const start = Math.min(Math.max(sinceOffset, 0), buf.length);
239
+ let pos = start;
240
+ let consumableEnd = start;
241
+ while (pos < buf.length) {
242
+ const nl = buf.indexOf(10, pos);
243
+ if (nl === -1) break;
244
+ const text = buf.subarray(pos, nl).toString("utf8").trim();
245
+ if (text) {
246
+ try {
247
+ lines.push({ record: JSON.parse(text), startOffset: pos });
248
+ } catch {
249
+ damagedLines += 1;
250
+ }
251
+ }
252
+ pos = nl + 1;
253
+ consumableEnd = pos;
254
+ }
255
+ return { lines, consumableEnd, damagedLines };
256
+ }
257
+ function groupByExecId(lines) {
258
+ const groups = /* @__PURE__ */ new Map();
259
+ for (const { record, startOffset } of lines) {
260
+ const g = groups.get(record.execId) ?? { startOffset };
261
+ if (record.rec === "started") {
262
+ g.started = record;
263
+ g.startOffset = startOffset;
264
+ } else if (record.rec === "finished") {
265
+ g.finished = record;
266
+ } else if (record.rec === "aborted") {
267
+ g.aborted = record;
268
+ }
269
+ groups.set(record.execId, g);
270
+ }
271
+ return groups;
272
+ }
273
+ function planHookExecMerge(opts = {}) {
274
+ const home = opts.home ?? homeFromEnv();
275
+ const nowMs = opts.nowMs ?? Date.now();
276
+ const sessionEnded = opts.sessionEnded ?? (() => false);
277
+ const sinceOffset = opts.fromOffset ?? readHookExecWatermark(home);
278
+ const { lines, consumableEnd, damagedLines } = readSpoolLinesSince(home, sinceOffset);
279
+ const groups = groupByExecId(lines);
280
+ const eventsBySession = /* @__PURE__ */ new Map();
281
+ const sessionGroupOffsets = /* @__PURE__ */ new Map();
282
+ const sessionNewestRecordMs = /* @__PURE__ */ new Map();
283
+ let fullyConsumedOffset = consumableEnd;
284
+ for (const group of groups.values()) {
285
+ const outcome = deriveHookOutcome(group, {
286
+ sessionEnded: group.started?.sessionId ? sessionEnded(group.started.sessionId) : false,
287
+ nowMs,
288
+ spoolDamaged: damagedLines > 0
289
+ });
290
+ if (outcome === "defer") {
291
+ fullyConsumedOffset = Math.min(fullyConsumedOffset, group.startOffset);
292
+ continue;
293
+ }
294
+ if (!outcome) continue;
295
+ const list = eventsBySession.get(outcome.sessionId) ?? [];
296
+ list.push(hookOutcomeToEvent(outcome));
297
+ eventsBySession.set(outcome.sessionId, list);
298
+ const startedMs = Date.parse(outcome.ts);
299
+ if (Number.isFinite(startedMs)) {
300
+ sessionNewestRecordMs.set(outcome.sessionId, Math.max(sessionNewestRecordMs.get(outcome.sessionId) ?? 0, startedMs));
301
+ }
302
+ const prior = sessionGroupOffsets.get(outcome.sessionId);
303
+ sessionGroupOffsets.set(outcome.sessionId, prior === void 0 ? group.startOffset : Math.min(prior, group.startOffset));
304
+ }
305
+ return {
306
+ eventsBySession,
307
+ sessionGroupOffsets,
308
+ sessionNewestRecordMs,
309
+ fullyConsumedOffset,
310
+ recordsRead: lines.length,
311
+ damagedLines
312
+ };
313
+ }
314
+ var HOOK_EXEC_SPOOL_RETENTION_MS = 14 * 24 * 60 * 60 * 1e3;
315
+ function spoolLineExpired(text, nowMs, retentionMs) {
316
+ let record;
317
+ try {
318
+ record = JSON.parse(text);
319
+ } catch {
320
+ return void 0;
321
+ }
322
+ if (record.rec !== "started") return void 0;
323
+ const startedMs = Date.parse(record.t);
324
+ if (!Number.isFinite(startedMs)) return false;
325
+ return nowMs - startedMs > retentionMs;
326
+ }
327
+ function pruneHookExecSpool(opts) {
328
+ const home = opts.home ?? homeFromEnv();
329
+ const nowMs = opts.nowMs ?? Date.now();
330
+ const retentionMs = opts.retentionMs ?? HOOK_EXEC_SPOOL_RETENTION_MS;
331
+ const path = hookExecSpoolPath(home);
332
+ let buf;
333
+ try {
334
+ buf = readFileSync(path);
335
+ } catch {
336
+ return 0;
337
+ }
338
+ const limit = Math.min(Math.max(opts.consumedOffset, 0), buf.length);
339
+ let dropTo = 0;
340
+ let pos = 0;
341
+ while (pos < limit) {
342
+ const nl = buf.indexOf(10, pos);
343
+ if (nl === -1 || nl + 1 > limit) break;
344
+ const text = buf.subarray(pos, nl).toString("utf8").trim();
345
+ if (text && spoolLineExpired(text, nowMs, retentionMs) === false) break;
346
+ pos = nl + 1;
347
+ dropTo = pos;
348
+ }
349
+ if (dropTo === 0) return 0;
350
+ const tmp = `${path}.prune`;
351
+ try {
352
+ writeFileSync(tmp, buf.subarray(dropTo));
353
+ opts.onSnapshot?.();
354
+ if (statSync(path).size !== buf.length) {
355
+ rmSync(tmp);
356
+ return 0;
357
+ }
358
+ writeHookExecWatermark(Math.max(opts.consumedOffset - dropTo, 0), home);
359
+ renameSync(tmp, path);
360
+ } catch {
361
+ try {
362
+ rmSync(tmp);
363
+ } catch {
364
+ }
365
+ return 0;
366
+ }
367
+ return dropTo;
368
+ }
369
+
370
+ // src/live-upload.ts
371
+ var DEFAULT_DEBOUNCE_MS = 2e3;
372
+ var RETRY_BASE_DELAY_MS = 2e3;
373
+ var RETRY_MAX_DELAY_MS = 6e4;
374
+ function liveUploadStatePath(home) {
375
+ return join(home, ".outerlayer", "spool", "live-uploads.json");
376
+ }
377
+ function readLiveUploadState(home) {
378
+ try {
379
+ const parsed = JSON.parse(readFileSync(liveUploadStatePath(home), "utf8"));
380
+ if (parsed && typeof parsed === "object" && parsed.v === 1) {
381
+ const sessions = parsed.sessions;
382
+ return { v: 1, sessions: sessions && typeof sessions === "object" ? sessions : {} };
383
+ }
384
+ } catch {
385
+ }
386
+ return { v: 1, sessions: {} };
387
+ }
388
+ function writeLiveUploadState(state, home) {
389
+ try {
390
+ const path = liveUploadStatePath(home);
391
+ mkdirSync(join(home, ".outerlayer", "spool"), { recursive: true });
392
+ const tmp = `${path}.tmp-${process.pid}`;
393
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
394
+ renameSync(tmp, path);
395
+ } catch {
396
+ }
397
+ }
398
+ function subagentInfo(mirrorPath) {
399
+ const segments = mirrorPath.split(/[/\\]/);
400
+ const subagentsIdx = segments.lastIndexOf("subagents");
401
+ if (subagentsIdx <= 0) return { isSubagent: false };
402
+ return {
403
+ isSubagent: true,
404
+ parentSessionId: segments[subagentsIdx - 1] || void 0,
405
+ ownId: sessionIdFromPath(mirrorPath)
406
+ };
407
+ }
408
+ function sessionKeyFor(mirrorPath) {
409
+ return sessionIdFromPath(mirrorPath);
410
+ }
411
+ function endSignalKeyFor(mirrorPath) {
412
+ return subagentInfo(mirrorPath).parentSessionId ?? sessionIdFromPath(mirrorPath);
413
+ }
414
+ function sessionHasEnded(home, sessionKey) {
415
+ let text;
416
+ try {
417
+ text = readFileSync(join(spoolDir(home), "events.jsonl"), "utf8");
418
+ } catch {
419
+ return false;
420
+ }
421
+ for (const rawLine of text.split("\n")) {
422
+ const trimmed = rawLine.trim();
423
+ if (!trimmed) continue;
424
+ let record;
425
+ try {
426
+ record = JSON.parse(trimmed);
427
+ } catch {
428
+ continue;
429
+ }
430
+ if (record.event === "SessionEnd" && record.sessionId === sessionKey) return true;
431
+ }
432
+ return false;
433
+ }
434
+ function groupTurns(turns, open, wholeCounts) {
435
+ const groups = [];
436
+ let current = [];
437
+ let currentBytes = 0;
438
+ for (const raw of turns) {
439
+ let turn = raw;
440
+ let bytes = JSON.stringify(turn).length + 1;
441
+ if (bytes > CHUNK_PART_TARGET_BYTES) {
442
+ turn = sliceTurnIo(raw);
443
+ bytes = JSON.stringify(turn).length + 1;
444
+ }
445
+ if (current.length > 0 && currentBytes + bytes > CHUNK_PART_TARGET_BYTES) {
446
+ groups.push(current);
447
+ current = [];
448
+ currentBytes = 0;
449
+ }
450
+ current.push(turn);
451
+ currentBytes += bytes;
452
+ }
453
+ if (current.length > 0) groups.push(current);
454
+ return groups.map((groupTurnsSlice, i) => {
455
+ const isLast = i === groups.length - 1;
456
+ const fromTurn = groupTurnsSlice[0].index;
457
+ const toTurn = groupTurnsSlice[groupTurnsSlice.length - 1].index;
458
+ return {
459
+ turns: groupTurnsSlice,
460
+ delta: {
461
+ fromTurn,
462
+ toTurn,
463
+ open: isLast ? open : true,
464
+ counts: wholeCounts
465
+ }
466
+ };
467
+ });
468
+ }
469
+ async function acceptedSessionIds(response) {
470
+ try {
471
+ const parsed = await response.json();
472
+ const accepted = parsed?.data?.accepted;
473
+ return Array.isArray(accepted) ? accepted : void 0;
474
+ } catch {
475
+ return void 0;
476
+ }
477
+ }
478
+ function retryAfterMs(response) {
479
+ const raw = response?.headers.get("retry-after");
480
+ return raw && /^\d+$/.test(raw) ? Number(raw) * 1e3 : 0;
481
+ }
482
+ var LiveUploader = class {
483
+ constructor(opts) {
484
+ this.cycles = /* @__PURE__ */ new Map();
485
+ /** Sessions this sender is done with: ended, or permanently rejected. The
486
+ * daemon keeps mirroring a transcript after its session closes, and every
487
+ * one of those events would otherwise open a fresh debounce cycle that
488
+ * re-reads the state file only to refuse the session again. */
489
+ this.finished = /* @__PURE__ */ new Set();
490
+ /** Set on a 401 — the key is unknown/expired/wrong app, which is true for
491
+ * every session, so the sender stops entirely for the rest of this
492
+ * process rather than retrying session by session. */
493
+ this.stopped = false;
494
+ this.home = opts.home;
495
+ this.fetchImpl = opts.fetchImpl ?? fetch;
496
+ this.sleepImpl = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
497
+ this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
498
+ this.env = opts.env ?? process.env;
499
+ this.stderr = opts.stderr ?? ((s) => void process.stderr.write(s));
500
+ }
501
+ /** Called for every daemon `mirrored` event. Cheap and synchronous — the
502
+ * actual read/send work happens on the debounced cycle. */
503
+ notify(mirrorPath) {
504
+ if (this.stopped) return;
505
+ const key = sessionKeyFor(mirrorPath);
506
+ if (this.finished.has(key)) return;
507
+ const existing = this.cycles.get(key);
508
+ if (existing) {
509
+ existing.latestFile = mirrorPath;
510
+ existing.generation += 1;
511
+ return;
512
+ }
513
+ const cycle = { latestFile: mirrorPath, generation: 0, loop: Promise.resolve() };
514
+ cycle.loop = this.runCycle(key, cycle).finally(() => {
515
+ if (this.cycles.get(key) === cycle) this.cycles.delete(key);
516
+ });
517
+ this.cycles.set(key, cycle);
518
+ }
519
+ /** Test-only: drains every debounce/retry cycle currently pending, with no
520
+ * real timers involved (the injected `sleepImpl` resolves however the
521
+ * test wants). Loops because a cycle's own outcome (a retry, or a fresh
522
+ * notify that arrived mid-send) can leave new work queued. */
523
+ async flush() {
524
+ while (this.cycles.size > 0) {
525
+ await Promise.allSettled([...this.cycles.values()].map((c) => c.loop));
526
+ }
527
+ }
528
+ /** Nothing awaits a cycle in the daemon — `notify()` starts it and returns
529
+ * — so an error that escaped here would be an unhandled rejection, and
530
+ * Node's default for those is to exit the process, taking the transcript
531
+ * mirroring down with the sender. A throw ends this session's cycle and
532
+ * nothing else; the next mirrored event for it starts a fresh one. */
533
+ async runCycle(key, cycle) {
534
+ try {
535
+ let processedGeneration = -1;
536
+ for (; ; ) {
537
+ for (; ; ) {
538
+ const generationAtWaitStart = cycle.generation;
539
+ await this.sleepImpl(this.debounceMs);
540
+ if (cycle.generation !== generationAtWaitStart) continue;
541
+ break;
542
+ }
543
+ if (cycle.generation === processedGeneration) return;
544
+ const generationAtProcess = cycle.generation;
545
+ const stop = await this.processOnce(key, cycle.latestFile);
546
+ if (stop) this.finished.add(key);
547
+ processedGeneration = generationAtProcess;
548
+ if (stop || cycle.generation === processedGeneration) return;
549
+ }
550
+ } catch {
551
+ }
552
+ }
553
+ /** One session's whole eligibility check + (if eligible) send, including
554
+ * any retries. Returns true when this session's cycle should stop for
555
+ * good (ended, permanently rejected, or the sender is globally stopped) —
556
+ * the caller records that, so no later mirrored event reopens a cycle for
557
+ * it. A hold that could lift on a later fire — no credentials yet,
558
+ * `autoSync` off, an undecidable launch spool, a misconfigured tier —
559
+ * returns false instead, because eligibility is re-checked on every fire. */
560
+ async processOnce(key, mirrorPath) {
561
+ const state = readLiveUploadState(this.home);
562
+ const priorEntry = state.sessions[key];
563
+ if (priorEntry?.ended || priorEntry?.failed) return true;
564
+ const cloudConfig = readCloudConfig(this.home);
565
+ if (cloudConfig.autoSync === false) return false;
566
+ if (!cloudConfig.url || !(cloudConfig.apiKey || cloudConfig.accountToken)) return false;
567
+ const info = subagentInfo(mirrorPath);
568
+ const tail = tailTranscript(mirrorPath, void 0, {
569
+ isSubagent: info.isSubagent,
570
+ parentSessionId: info.parentSessionId,
571
+ ownId: info.ownId
572
+ });
573
+ if (!tail.session) return false;
574
+ const session = tail.session;
575
+ enrichSessionRepo(session);
576
+ const destination = resolveGatewayCredentials({ home: this.home, env: {}, cwd: session.env.cwd ?? "/" });
577
+ if (!destination.ok) return false;
578
+ const repoFilter = new RepoFilter(cloudConfig.repos);
579
+ if (!repoFilter.allows(session.env.gitRepo)) return false;
580
+ const launchSpool = readLaunchSpoolState(this.home);
581
+ if (!launchStatusIsDecisive(launchSpool)) return false;
582
+ const launchFilter = new LaunchFilter(launchSpool.ids);
583
+ if (!launchFilter.allows(session)) return false;
584
+ let tier;
585
+ try {
586
+ tier = resolveTier(cloudConfig.tier);
587
+ } catch (err) {
588
+ if (err instanceof SyncConfigError) return false;
589
+ throw err;
590
+ }
591
+ const hookEvents = planHookExecMerge({ home: this.home, fromOffset: 0 }).eventsBySession.get(session.id);
592
+ if (hookEvents && hookEvents.length > 0) appendHookExecEvents(session, hookEvents);
593
+ applyContextProvenance(session, readLatestProvenanceForSession(session.id, this.home));
594
+ applyReportedCost(session, this.home);
595
+ installScrubContext(this.home, cloudConfig.scrub);
596
+ const prepared = prepareSessionForUpload(session, tier, this.env);
597
+ const ackedTurnIndex = priorEntry?.ackedTurnIndex ?? -1;
598
+ const fromTurn = ackedTurnIndex + 1;
599
+ const available = prepared.turns.filter((t) => t.index >= fromTurn);
600
+ const open = !sessionHasEnded(this.home, endSignalKeyFor(mirrorPath));
601
+ if (available.length === 0 && open) return false;
602
+ const wholeCounts = wholeSessionCounts(prepared.turns);
603
+ const groups = groupTurns(available, open, wholeCounts);
604
+ const emptyFinalTurn = Math.max(ackedTurnIndex, 0);
605
+ const toSend = groups.length > 0 ? groups : [{ turns: [], delta: { fromTurn: emptyFinalTurn, toTurn: emptyFinalTurn, open: false, counts: wholeCounts } }];
606
+ const endpoint = new URL("/v1/agents/sync", destination.url).toString();
607
+ let currentAcked = ackedTurnIndex;
608
+ for (const group of toSend) {
609
+ const payload = {
610
+ schemaVersion: 1,
611
+ sessions: [{ ...prepared, turns: group.turns, delta: group.delta }]
612
+ };
613
+ const outcome = await this.sendWithRetry(endpoint, destination.credential, destination.appId, prepared.id, payload);
614
+ if (outcome === "unauthorized") {
615
+ this.stopped = true;
616
+ this.stderr(
617
+ destination.credential.startsWith("olu_") ? `live upload stopped: the login was refused (expired or revoked). No session streams from this daemon until you run \`outerlayer login\` and it restarts.
618
+ ` : `live upload stopped: the API key was refused. No session streams from this daemon until it restarts with a valid key.
619
+ `
620
+ );
621
+ return true;
622
+ }
623
+ if (outcome === "rejected") {
624
+ const reason = "the server refused this delta permanently";
625
+ this.mergeSessionState(key, {
626
+ ackedTurnIndex: currentAcked,
627
+ failed: true,
628
+ lastError: reason,
629
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
630
+ });
631
+ this.stderr(`live upload stopped for session ${prepared.id}: ${reason}. The next \`outerlayer sync\` uploads it whole.
632
+ `);
633
+ return true;
634
+ }
635
+ currentAcked = group.delta.open ? Math.max(currentAcked, group.delta.toTurn - 1) : Math.max(currentAcked, group.delta.toTurn);
636
+ this.mergeSessionState(key, {
637
+ ackedTurnIndex: currentAcked,
638
+ ended: !group.delta.open,
639
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
640
+ });
641
+ }
642
+ return !open;
643
+ }
644
+ /** Writes one session's entry into the state file, re-reading it first.
645
+ * Every session on the machine shares this file and each cycle awaits the
646
+ * network between its read and its write, so a write built from a snapshot
647
+ * taken before the request would revert whatever another session recorded
648
+ * in the meantime — its acked index, its `ended`, or its `failed`. */
649
+ mergeSessionState(key, entry) {
650
+ const current = readLiveUploadState(this.home);
651
+ writeLiveUploadState({ v: 1, sessions: { ...current.sessions, [key]: entry } }, this.home);
652
+ }
653
+ /** The headers that sign a request with this host's key, or none when the host has no readable key. A runner
654
+ * key bound to a host key is refused on every route unless that key signed the request. A login token is
655
+ * never signed: only the saved factory key is bound to a host key. */
656
+ async signatureHeaders(endpoint, body, credential) {
657
+ if (readCloudConfig(this.home).apiKey !== credential) return {};
658
+ let signer;
659
+ try {
660
+ signer = await signerFor(cloudConfigPath(this.home));
661
+ } catch {
662
+ return {};
663
+ }
664
+ if (signer === void 0) return {};
665
+ return { ...await signRunnerRequest({ method: "POST", url: endpoint, body: new TextEncoder().encode(body), signer }) };
666
+ }
667
+ /** Sends one request, retrying a network error or a retryable 5xx forever
668
+ * with exponential backoff (2s → 60s cap) — the daemon runs unattended, so
669
+ * there is no bounded attempt count. A 401 or a permanent 4xx/rejection
670
+ * returns immediately instead of retrying. */
671
+ async sendWithRetry(endpoint, apiKey, appId, sessionId, payload) {
672
+ const body = JSON.stringify(payload);
673
+ let delayMs = RETRY_BASE_DELAY_MS;
674
+ for (; ; ) {
675
+ let response;
676
+ try {
677
+ const signature = await this.signatureHeaders(endpoint, body, apiKey);
678
+ response = await this.fetchImpl(endpoint, {
679
+ method: "POST",
680
+ headers: {
681
+ "content-type": "application/json",
682
+ authorization: `Bearer ${apiKey}`,
683
+ "x-outerlayer-app-id": appId,
684
+ ...signature
685
+ },
686
+ body
687
+ });
688
+ } catch {
689
+ response = void 0;
690
+ }
691
+ if (response) {
692
+ if (response.status === 401) return "unauthorized";
693
+ if (response.ok) {
694
+ const accepted = await acceptedSessionIds(response);
695
+ if (accepted) return accepted.includes(sessionId) ? "accepted" : "rejected";
696
+ } else if (!RETRYABLE_STATUSES.has(response.status)) {
697
+ return "rejected";
698
+ }
699
+ }
700
+ await this.sleepImpl(Math.max(retryAfterMs(response), delayMs));
701
+ delayMs = Math.min(delayMs * 2, RETRY_MAX_DELAY_MS);
702
+ }
703
+ }
704
+ };
705
+
706
+ // src/skip-verdict.ts
707
+ var RESUME_WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
708
+ function unlaunchedVerdict(opts) {
709
+ if (!opts.launchStatusDecisive) return "unknown";
710
+ return opts.idleMs < RESUME_WINDOW_MS ? "reversible" : "final";
711
+ }
712
+ function statusUnknown(verdict) {
713
+ return verdict === "unknown";
714
+ }
715
+ function mightStillShip(verdict) {
716
+ return verdict !== "final";
717
+ }
718
+
719
+ // src/sync-cmd.ts
720
+ var ITEM_KEY_PREFIX = "olitem_";
721
+ function isItemKey(token) {
722
+ return token.startsWith(ITEM_KEY_PREFIX);
723
+ }
724
+ function isLoopbackUrl(url) {
725
+ try {
726
+ const host = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, "");
727
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "0.0.0.0" || /^127\./.test(host);
728
+ } catch {
729
+ return false;
730
+ }
731
+ }
732
+ var MAX_SESSIONS_PER_BATCH = 25;
733
+ var MAX_BLOBS_PER_BATCH = 64;
734
+ var MAX_BLOB_BYTES_PER_BATCH = 4e6;
735
+ var MAX_SINGLE_BLOB_BYTES = MAX_BLOB_BYTES_PER_BATCH;
736
+ var MAX_BATCH_JSON_BYTES = 35e5;
737
+ var SESSION_CHUNK_TRIGGER_BYTES = 1e6;
738
+ var CHUNK_PART_TARGET_BYTES = 7e5;
739
+ function blobsForTurns(blobs, turns) {
740
+ const shas = /* @__PURE__ */ new Set();
741
+ for (const t of turns) for (const img of t.images ?? []) shas.add(img.sha256);
742
+ return blobs.filter((b) => shas.has(b.sha256));
743
+ }
744
+ function chunkOversizedSession(item) {
745
+ if (JSON.stringify(item.session).length <= SESSION_CHUNK_TRIGGER_BYTES) return [item];
746
+ const turns = item.session.turns ?? [];
747
+ const counts = wholeSessionCounts(turns);
748
+ const envelopeBytes = JSON.stringify({ ...item.session, turns: [] }).length;
749
+ const budget = Math.max(CHUNK_PART_TARGET_BYTES - envelopeBytes, 1e5);
750
+ const parts = [];
751
+ let cur = [];
752
+ let curBytes = 0;
753
+ for (const raw of turns) {
754
+ let turn = raw;
755
+ let bytes = JSON.stringify(turn).length + 1;
756
+ if (bytes > budget) {
757
+ turn = sliceTurnIo(raw);
758
+ bytes = JSON.stringify(turn).length + 1;
759
+ }
760
+ if (cur.length > 0 && curBytes + bytes > budget) {
761
+ parts.push(cur);
762
+ cur = [];
763
+ curBytes = 0;
764
+ }
765
+ cur.push(turn);
766
+ curBytes += bytes;
767
+ }
768
+ if (cur.length > 0) parts.push(cur);
769
+ return parts.map((partTurns, i) => ({
770
+ session: {
771
+ ...item.session,
772
+ turns: partTurns,
773
+ chunk: { part: i + 1, of: parts.length, counts }
774
+ },
775
+ blobs: blobsForTurns(item.blobs, partTurns),
776
+ mtimeMs: item.mtimeMs
777
+ }));
778
+ }
779
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([500, 502, 503, 504]);
780
+ var MAX_BATCH_ATTEMPTS = 6;
781
+ var RETRY_BASE_DELAY_MS2 = 2e3;
782
+ var KNOWN_PERMANENT_ARTIFACT_ERROR_CODES = /* @__PURE__ */ new Set([
783
+ "invalid_request_body",
784
+ "invalid_field_value",
785
+ "session_not_found",
786
+ "unknown_replaces_target",
787
+ "nothing_to_attach",
788
+ "artifact_content_conflict",
789
+ "payload_too_large"
790
+ ]);
791
+ var NEVER_REACHED_SERVER_ERROR_CODES = /* @__PURE__ */ new Set([
792
+ "invalid_request_body",
793
+ "invalid_field_value",
794
+ "session_not_found",
795
+ "nothing_to_attach",
796
+ "payload_too_large"
797
+ ]);
798
+ function splitPayload(payload) {
799
+ const mid = Math.ceil(payload.sessions.length / 2);
800
+ const first = payload.sessions.slice(0, mid);
801
+ const second = payload.sessions.slice(mid);
802
+ const shasOf = (sessions) => {
803
+ const set = /* @__PURE__ */ new Set();
804
+ for (const s of sessions) {
805
+ const turns = s.turns ?? [];
806
+ for (const t of turns) for (const img of t.images ?? []) if (typeof img.sha256 === "string") set.add(img.sha256);
807
+ }
808
+ return set;
809
+ };
810
+ const firstShas = shasOf(first);
811
+ const secondShas = shasOf(second);
812
+ const blobs = payload.blobs ?? [];
813
+ const firstBlobs = blobs.filter((b) => firstShas.has(b.sha256) || !secondShas.has(b.sha256));
814
+ const secondBlobs = blobs.filter((b) => secondShas.has(b.sha256));
815
+ return [
816
+ { schemaVersion: 1, sessions: first, ...firstBlobs.length > 0 ? { blobs: firstBlobs } : {} },
817
+ { schemaVersion: 1, sessions: second, ...secondBlobs.length > 0 ? { blobs: secondBlobs } : {} }
818
+ ];
819
+ }
820
+ var SyncConfigError = class extends Error {
821
+ };
822
+ var SyncTransportError = class extends Error {
823
+ constructor(message, status) {
824
+ super(message);
825
+ this.status = status;
826
+ this.name = "SyncTransportError";
827
+ }
828
+ };
829
+ function resolveTier(raw) {
830
+ if (raw === void 0) return "full";
831
+ if (CAPTURE_TIERS.includes(raw)) return raw;
832
+ throw new SyncConfigError(`unknown tier "${raw}" \u2014 use ${CAPTURE_TIERS.join(" | ")}`);
833
+ }
834
+ function entrySessionId(entry) {
835
+ return entry.ownId ?? basename(entry.file).replace(/\.jsonl$/, "");
836
+ }
837
+ function sinceAdapters(sinceMs, alsoSessionIds = /* @__PURE__ */ new Set()) {
838
+ return SOURCE_ADAPTERS.map((adapter) => ({
839
+ ...adapter,
840
+ discover: (roots) => {
841
+ const entries = adapter.discover(roots);
842
+ if (alsoSessionIds.size === 0) return entries.filter((entry) => entry.mtimeMs > sinceMs);
843
+ return entries.filter((entry) => entry.mtimeMs > sinceMs || alsoSessionIds.has(entrySessionId(entry)));
844
+ }
845
+ }));
846
+ }
847
+ function fmtBytes(n) {
848
+ if (n < 1024) return `${n} B`;
849
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
850
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
851
+ }
852
+ function fmtAge(ms) {
853
+ if (!Number.isFinite(ms)) return "?";
854
+ const minutes = Math.max(0, Math.floor(ms / 6e4));
855
+ if (minutes < 60) return `${minutes}m`;
856
+ const hours = Math.floor(minutes / 60);
857
+ if (hours < 48) return `${hours}h`;
858
+ return `${Math.floor(hours / 24)}d`;
859
+ }
860
+ async function runSync(opts = {}) {
861
+ const refusal = staleBuildRefusal(opts.env ?? process.env);
862
+ if (refusal) throw new SyncConfigError(refusal);
863
+ const staleBuild = staleBuildWarning();
864
+ if (staleBuild && !opts.quiet) process.stderr.write(`${C.yellow}!${C.reset} ${staleBuild}
865
+ `);
866
+ const home = opts.home ?? homeFromEnv(opts.env ?? process.env);
867
+ const stats = {};
868
+ try {
869
+ const result = await runSyncInner(opts, stats);
870
+ if (!result.dryRun) {
871
+ writeSyncStatus(
872
+ {
873
+ at: (/* @__PURE__ */ new Date()).toISOString(),
874
+ ok: stats.blocked === void 0,
875
+ contacted: stats.contacted === true,
876
+ url: result.url,
877
+ appId: stats.appId,
878
+ synced: result.synced,
879
+ pending: 0,
880
+ ...stats.skippedNotLaunched ? { skippedNotLaunched: stats.skippedNotLaunched } : {},
881
+ ...stats.held ? { held: stats.held } : {},
882
+ ...stats.blocked !== void 0 ? { error: { message: stats.blocked } } : {}
883
+ },
884
+ home
885
+ );
886
+ }
887
+ return result;
888
+ } catch (err) {
889
+ writeSyncStatus(
890
+ {
891
+ at: (/* @__PURE__ */ new Date()).toISOString(),
892
+ ok: false,
893
+ url: stats.url,
894
+ appId: stats.appId,
895
+ synced: stats.synced ?? 0,
896
+ pending: stats.pending,
897
+ error: {
898
+ status: err instanceof SyncTransportError ? err.status : void 0,
899
+ message: err instanceof Error ? err.message : String(err)
900
+ }
901
+ },
902
+ home
903
+ );
904
+ throw err;
905
+ }
906
+ }
907
+ function parseWorkItemNumber(raw) {
908
+ const match = /^\d+$/.exec(raw ?? "");
909
+ return match ? Number(match[0]) : void 0;
910
+ }
911
+ async function runSyncInner(opts, stats) {
912
+ const envVars = opts.env ?? process.env;
913
+ const home = opts.home ?? homeFromEnv(envVars);
914
+ const fileConfig = readCloudConfig(home);
915
+ const resolved = resolveGatewayCredentials({ ...opts, home, env: envVars });
916
+ stats.url = resolved.url;
917
+ stats.appId = resolved.appId;
918
+ const tier = resolveTier(opts.tier ?? envVars.OUTERLAYER_TIER ?? fileConfig.tier);
919
+ const dryRun = opts.dryRun ?? false;
920
+ const repoFilter = new RepoFilter(fileConfig.repos);
921
+ const launchSpool = readLaunchSpoolState(home);
922
+ const launchedSessionIds = launchSpool.ids;
923
+ const launchFilter = new LaunchFilter(launchedSessionIds);
924
+ const itemScope = resolved.credential !== void 0 && isItemKey(resolved.credential) ? { item: parseWorkItemNumber(envVars.OUTERLAYER_WORK), sessionItems: launchedItemsBySession(home) } : void 0;
925
+ const inItemScope = (sessionId) => itemScope === void 0 || itemScope.item !== void 0 && itemScope.sessionItems.get(sessionId) === itemScope.item;
926
+ const connectionScoped = resolved.kind === "login" && opts.appId === void 0 && !envVars.OUTERLAYER_APP_ID;
927
+ const runNowMs = Date.now();
928
+ const launchSkipVerdict = (mtimeMs) => unlaunchedVerdict({ launchStatusDecisive: launchStatusIsDecisive(launchSpool), idleMs: runNowMs - mtimeMs });
929
+ const launchSkipReason = () => {
930
+ if (launchSpool.unreadable !== void 0) return "the launch spool could not be read, so none could be checked";
931
+ if (launchSpool.malformedLines !== void 0) {
932
+ return `the launch spool has ${launchSpool.malformedLines} damaged line(s), so these could not be checked`;
933
+ }
934
+ return "not launched with OUTERLAYER_WORK";
935
+ };
936
+ const artifactHoldReason = (sessionId) => {
937
+ if (launchedSessionIds.has(sessionId)) {
938
+ return inItemScope(sessionId) ? void 0 : "its session was launched for another work item";
939
+ }
940
+ if (launchSpool.unreadable !== void 0) {
941
+ return "the launch spool could not be read this run, so no artifact may leave the machine";
942
+ }
943
+ if (launchSpool.malformedLines !== void 0) {
944
+ return "the launch spool has damaged lines, so its session's launch could not be checked";
945
+ }
946
+ return "its session was not launched with OUTERLAYER_WORK, so it may not leave the machine";
947
+ };
948
+ if (launchSpool.unreadable !== void 0) {
949
+ stats.blocked = `the launch spool at ${launchesPath(home)} could not be read (${launchSpool.unreadable}) \u2014 no session could be checked for a launch record, so nothing uploaded`;
950
+ if (!opts.quiet) {
951
+ process.stderr.write(
952
+ `${C.yellow}!${C.reset} the launch spool at ${launchesPath(home)} could not be read (${launchSpool.unreadable}) \u2014 nothing uploads this run, and no session is passed over. Fix the file's permissions and sync again.
953
+ `
954
+ );
955
+ }
956
+ } else if (launchSpool.malformedLines !== void 0) {
957
+ stats.blocked = `${launchSpool.malformedLines} line(s) in the launch spool at ${launchesPath(home)} could not be parsed \u2014 a session with no launch record cannot be told from one those lines named, so any such session is held instead of uploaded`;
958
+ if (!opts.quiet)
959
+ process.stderr.write(
960
+ `${C.yellow}!${C.reset} ${launchSpool.malformedLines} line(s) in the launch spool at ${launchesPath(home)} could not be parsed, so a session with no launch record cannot be told from one those lines named. Nothing is passed over; sessions the spool does name still upload.
961
+ `
962
+ );
963
+ }
964
+ installScrubContext(home, fileConfig.scrub);
965
+ if (!resolved.ok) throw new SyncConfigError(resolved.message);
966
+ const { url, credential: apiKey, appId } = resolved;
967
+ {
968
+ const sinceMs = opts.all ? 0 : readWatermark(url, appId, home);
969
+ const hookPlan = planHookExecMerge({ home });
970
+ const mergedHookEndKeys = /* @__PURE__ */ new Map();
971
+ const hookRedeliverySessionIds = new Set(
972
+ [...hookPlan.eventsBySession.keys()].filter((sessionId) => launchedSessionIds.has(sessionId))
973
+ );
974
+ if (hookPlan.damagedLines > 0 && !opts.quiet) {
975
+ process.stderr.write(
976
+ `${C.yellow}!${C.reset} ${hookPlan.damagedLines} line(s) in the hook-execution spool at ${hookExecSpoolPath(home)} could not be parsed \u2014 a hook that did not report an outcome is left unrecorded this run rather than reported as a crash.
977
+ `
978
+ );
979
+ }
980
+ if (!dryRun) migrateLegacyArtifactSidecars(home);
981
+ const artifactPlan = planArtifactUpload({ home });
982
+ if (!opts.quiet) {
983
+ if (artifactPlan.corrupt) {
984
+ process.stderr.write(
985
+ `${C.yellow}!${C.reset} artifact spool state at ${artifactsStatePath(home)} is corrupt \u2014 treating it as empty; a record's saved disposition may be forgotten (a safe, if wasteful, re-upload for anything already synced)
986
+ `
987
+ );
988
+ }
989
+ for (const { offset, reason } of artifactPlan.skipped) {
990
+ process.stderr.write(`${C.yellow}!${C.reset} artifact spool line at offset ${offset} skipped \u2014 ${reason}
991
+ `);
992
+ }
993
+ for (const { offset, reason } of artifactPlan.held) {
994
+ process.stderr.write(`${C.yellow}!${C.reset} artifact spool line at offset ${offset} held \u2014 ${reason}
995
+ `);
996
+ }
997
+ }
998
+ const combinedRecords = artifactPlan.candidates;
999
+ const contextSourceBySessionId = /* @__PURE__ */ new Map();
1000
+ for (const record of readContextProvenance(home)) {
1001
+ contextSourceBySessionId.set(record.sessionId, record);
1002
+ }
1003
+ const liveUploads = readLiveUploadState(home).sessions;
1004
+ const collected = [];
1005
+ const observedRepos = /* @__PURE__ */ new Set();
1006
+ let heldMinMtimeMs = Number.POSITIVE_INFINITY;
1007
+ let passableMaxMtimeMs = 0;
1008
+ const neverShipSessionIds = /* @__PURE__ */ new Set();
1009
+ const noteSkipped = (sessionId, mtimeMs, verdict) => {
1010
+ if (statusUnknown(verdict)) heldMinMtimeMs = Math.min(heldMinMtimeMs, mtimeMs);
1011
+ else passableMaxMtimeMs = Math.max(passableMaxMtimeMs, mtimeMs);
1012
+ if (!mightStillShip(verdict) && typeof sessionId === "string") neverShipSessionIds.add(sessionId);
1013
+ };
1014
+ const { report } = scanAll({
1015
+ root: opts.root,
1016
+ rawRoot: opts.rawRoot,
1017
+ codexRoot: opts.codexRoot,
1018
+ cursorRoot: opts.cursorRoot,
1019
+ cursorProjectsRoot: opts.cursorProjectsRoot,
1020
+ limit: opts.limit,
1021
+ captureTier: tier,
1022
+ adapters: sinceAdapters(sinceMs, hookRedeliverySessionIds),
1023
+ onSession: (session, entry, blobs) => {
1024
+ enrichSessionRepo(session);
1025
+ setRepoRoot(resolveRepoIdentity(session.env.cwd).repoRoot);
1026
+ if (!repoFilter.allows(session.env?.gitRepo)) {
1027
+ noteSkipped(session.id, entry.mtimeMs, "reversible");
1028
+ return;
1029
+ }
1030
+ if (!launchFilter.allows(session)) {
1031
+ noteSkipped(session.id, entry.mtimeMs, launchSkipVerdict(entry.mtimeMs));
1032
+ return;
1033
+ }
1034
+ if (connectionScoped) {
1035
+ const sessionFactory = readRepoConnection(session.env.cwd ?? "/")?.appId;
1036
+ if (sessionFactory !== appId) {
1037
+ const cwd = session.env.cwd ?? "/";
1038
+ const connectable = sessionFactory === void 0 && findGitDirs(cwd) !== void 0;
1039
+ noteSkipped(session.id, entry.mtimeMs, connectable ? "unknown" : "reversible");
1040
+ return;
1041
+ }
1042
+ }
1043
+ const scopeKey = session.subagent?.parentSessionId ?? session.id;
1044
+ if (!inItemScope(scopeKey)) {
1045
+ noteSkipped(session.id, entry.mtimeMs, "unknown");
1046
+ return;
1047
+ }
1048
+ {
1049
+ const identity = resolveRepoIdentity(session.env.cwd);
1050
+ if (identity.repoRoot) observedRepos.add(identity.repoRoot);
1051
+ }
1052
+ const hookEvents = hookPlan.eventsBySession.get(session.id);
1053
+ if (hookEvents && hookEvents.length > 0) {
1054
+ appendHookExecEvents(session, hookEvents);
1055
+ mergedHookEndKeys.set(session.id, session.subagent?.parentSessionId ?? session.id);
1056
+ }
1057
+ applyContextProvenance(session, contextSourceBySessionId.get(session.id));
1058
+ applyReportedCost(session, home);
1059
+ const gated = prepareSessionForUpload(session, tier, envVars);
1060
+ const liveEntry = liveUploads[gated.id];
1061
+ const daemonIsStreaming = liveEntry !== void 0 && !liveEntry.ended && !liveEntry.failed;
1062
+ if (daemonIsStreaming && !sessionHasEnded(home, gated.subagent?.parentSessionId ?? gated.id)) {
1063
+ const gatedTurns = gated.turns ?? [];
1064
+ gated.delta = {
1065
+ fromTurn: 0,
1066
+ toTurn: gatedTurns.reduce((max, t) => Math.max(max, t.index), 0),
1067
+ open: true,
1068
+ counts: wholeSessionCounts(gatedTurns)
1069
+ };
1070
+ }
1071
+ collected.push({
1072
+ session: gated,
1073
+ blobs: tier === "full" ? blobs : [],
1074
+ mtimeMs: entry.mtimeMs
1075
+ });
1076
+ }
1077
+ });
1078
+ if (report.droppedByLimitMinMtimeMs !== void 0) {
1079
+ noteSkipped(void 0, report.droppedByLimitMinMtimeMs, "unknown");
1080
+ }
1081
+ const launchDecisive = launchStatusIsDecisive(launchSpool);
1082
+ const worthHolding = (sessionId) => {
1083
+ if (!launchDecisive) return true;
1084
+ const newestRecordMs = hookPlan.sessionNewestRecordMs.get(sessionId);
1085
+ return newestRecordMs === void 0 || runNowMs - newestRecordMs < RESUME_WINDOW_MS;
1086
+ };
1087
+ const endedByKey = /* @__PURE__ */ new Map();
1088
+ const mergedSessionHasEnded = (sessionId) => {
1089
+ const key = mergedHookEndKeys.get(sessionId);
1090
+ if (key === void 0) return false;
1091
+ let ended = endedByKey.get(key);
1092
+ if (ended === void 0) {
1093
+ ended = sessionHasEnded(home, key);
1094
+ endedByKey.set(key, ended);
1095
+ }
1096
+ return ended;
1097
+ };
1098
+ let hookWatermarkOffset = hookPlan.fullyConsumedOffset;
1099
+ for (const [sessionId, offset] of hookPlan.sessionGroupOffsets) {
1100
+ if (mergedSessionHasEnded(sessionId)) continue;
1101
+ if (neverShipSessionIds.has(sessionId)) continue;
1102
+ if (!worthHolding(sessionId)) continue;
1103
+ hookWatermarkOffset = Math.min(hookWatermarkOffset, offset);
1104
+ }
1105
+ const watermarkCeilingMs = heldMinMtimeMs === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : heldMinMtimeMs - 1;
1106
+ stats.pending = collected.length;
1107
+ if (launchStatusIsDecisive(launchSpool)) stats.skippedNotLaunched = launchFilter.skippedTotal;
1108
+ else stats.held = launchFilter.skippedTotal;
1109
+ if (!opts.dryRun) {
1110
+ for (const repoRoot of observedRepos) removeJournalHooks(repoRoot);
1111
+ }
1112
+ collected.sort((a, b) => a.mtimeMs - b.mtimeMs);
1113
+ const items = collected.flatMap((item) => chunkOversizedSession(item));
1114
+ const batches = [];
1115
+ {
1116
+ let current = { schemaVersion: 1, sessions: [] };
1117
+ let currentBlobs = /* @__PURE__ */ new Map();
1118
+ let currentBytes = 0;
1119
+ let currentBlobBytes = 0;
1120
+ let currentMax = 0;
1121
+ const flush = () => {
1122
+ if (current.sessions.length === 0) return;
1123
+ if (currentBlobs.size > 0) current.blobs = [...currentBlobs.values()];
1124
+ batches.push({ payload: current, maxMtimeMs: currentMax });
1125
+ current = { schemaVersion: 1, sessions: [] };
1126
+ currentBlobs = /* @__PURE__ */ new Map();
1127
+ currentBytes = 0;
1128
+ currentBlobBytes = 0;
1129
+ currentMax = 0;
1130
+ };
1131
+ for (const item of items) {
1132
+ const size = JSON.stringify(item.session).length + item.blobs.reduce((n, b) => n + b.data.length, 0);
1133
+ let newBlobs = item.blobs.filter((b) => !currentBlobs.has(b.sha256));
1134
+ const newBlobBytes = newBlobs.reduce((n, b) => n + b.data.length, 0);
1135
+ if (current.sessions.length >= MAX_SESSIONS_PER_BATCH || currentBytes > 0 && currentBytes + size > MAX_BATCH_JSON_BYTES || current.sessions.length > 0 && currentBlobs.size + newBlobs.length > MAX_BLOBS_PER_BATCH || current.sessions.length > 0 && currentBlobBytes + newBlobBytes > MAX_BLOB_BYTES_PER_BATCH) {
1136
+ flush();
1137
+ newBlobs = item.blobs.filter((b) => !currentBlobs.has(b.sha256));
1138
+ }
1139
+ const packed = [];
1140
+ let packedBytes = currentBlobBytes;
1141
+ for (const blob of newBlobs) {
1142
+ if (currentBlobs.size + packed.length >= MAX_BLOBS_PER_BATCH) break;
1143
+ if (blob.data.length >= MAX_SINGLE_BLOB_BYTES) continue;
1144
+ if (packedBytes + blob.data.length > MAX_BLOB_BYTES_PER_BATCH) continue;
1145
+ packed.push(blob);
1146
+ packedBytes += blob.data.length;
1147
+ }
1148
+ if (packed.length < newBlobs.length) {
1149
+ process.stderr.write(
1150
+ `${C.yellow}!${C.reset} session ${item.session.id ?? "?"} references ${newBlobs.length} images (${Math.round(newBlobBytes / 1024)} KB) \u2014 shipping ${packed.length}, the rest stay local
1151
+ `
1152
+ );
1153
+ }
1154
+ current.sessions.push(item.session);
1155
+ for (const blob of packed) if (!currentBlobs.has(blob.sha256)) currentBlobs.set(blob.sha256, blob);
1156
+ currentBytes += size;
1157
+ currentBlobBytes = packedBytes;
1158
+ currentMax = Math.max(currentMax, item.mtimeMs);
1159
+ }
1160
+ flush();
1161
+ }
1162
+ const blobsTotal = batches.reduce((n, b) => n + (b.payload.blobs?.length ?? 0), 0);
1163
+ const blobBytes = batches.reduce(
1164
+ (n, b) => n + (b.payload.blobs?.reduce((m, blob) => m + blob.bytes, 0) ?? 0),
1165
+ 0
1166
+ );
1167
+ if (dryRun) {
1168
+ let output2;
1169
+ if (opts.json) {
1170
+ output2 = JSON.stringify(
1171
+ {
1172
+ url,
1173
+ appId,
1174
+ tier,
1175
+ batches: batches.map((b) => b.payload),
1176
+ artifacts: {
1177
+ pending: combinedRecords.length,
1178
+ records: combinedRecords.map(({ record }) => {
1179
+ const holdReason = artifactHoldReason(record.sessionId);
1180
+ return {
1181
+ filename: record.filename,
1182
+ kind: inferArtifactKind(record.mediaType),
1183
+ sessionId: record.sessionId,
1184
+ emittedAt: record.t,
1185
+ ...holdReason !== void 0 ? { held: true, heldReason: holdReason } : {}
1186
+ };
1187
+ })
1188
+ }
1189
+ },
1190
+ null,
1191
+ 2
1192
+ );
1193
+ } else {
1194
+ const lines = [];
1195
+ lines.push(`${C.yellow}DRY RUN${C.reset} \u2014 nothing left this machine.`);
1196
+ lines.push("");
1197
+ lines.push(` destination ${url} ${C.dim}(app ${appId})${C.reset}`);
1198
+ lines.push(` tier ${tier}`);
1199
+ const stripped = bannedPathsForTier(tier);
1200
+ lines.push(
1201
+ stripped.length > 0 ? ` stripped ${C.dim}${stripped.join(", ")}${C.reset}` : ` stripped ${C.dim}nothing \u2014 full content ships${C.reset}`
1202
+ );
1203
+ lines.push(` sessions ${collected.length} (of ${report.scanned} scanned) in ${batches.length} batch(es)`);
1204
+ lines.push(` images ${blobsTotal} blob(s), ${fmtBytes(blobBytes)}`);
1205
+ if (combinedRecords.length > 0) {
1206
+ const heldRecords = combinedRecords.filter(({ record }) => artifactHoldReason(record.sessionId) !== void 0);
1207
+ lines.push(
1208
+ ` artifacts ${combinedRecords.length - heldRecords.length} pending upload` + (heldRecords.length > 0 ? `, ${heldRecords.length} held` : "")
1209
+ );
1210
+ for (const { record } of combinedRecords.slice(0, 10)) {
1211
+ const holdReason = artifactHoldReason(record.sessionId);
1212
+ lines.push(
1213
+ ` ${C.dim}${sanitizeTerminal(record.sessionId).slice(0, 8)}${C.reset} ${inferArtifactKind(record.mediaType)} ${sanitizeTerminal(record.filename)} ${C.dim}${fmtAge(Date.now() - Date.parse(record.t))}${C.reset}` + (holdReason !== void 0 ? ` ${C.yellow}held${C.reset} ${C.dim}\u2014 ${holdReason}${C.reset}` : "")
1214
+ );
1215
+ }
1216
+ if (combinedRecords.length > 10) {
1217
+ lines.push(` ${C.dim}\u2026 and ${combinedRecords.length - 10} more${C.reset}`);
1218
+ }
1219
+ }
1220
+ const byRepo = /* @__PURE__ */ new Map();
1221
+ for (const item of collected) {
1222
+ const r = item.session.env?.gitRepo || "(no repo)";
1223
+ byRepo.set(r, (byRepo.get(r) ?? 0) + 1);
1224
+ }
1225
+ const repoRows = [...byRepo.entries()].sort((a, b) => b[1] - a[1]);
1226
+ lines.push(` repos ${repoRows.length} distinct:`);
1227
+ for (const [r, n] of repoRows.slice(0, 15)) lines.push(` ${C.dim}${String(n).padStart(6)}${C.reset} ${r}`);
1228
+ if (repoRows.length > 15) lines.push(` ${C.dim}\u2026 and ${repoRows.length - 15} more${C.reset}`);
1229
+ if (repoFilter.skippedTotal > 0) {
1230
+ lines.push(` ${C.yellow}skipped${C.reset} ${repoFilter.skippedTotal} session(s) by repo filter:`);
1231
+ for (const [r, n] of [...repoFilter.skipped.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)) {
1232
+ lines.push(` ${C.dim}${String(n).padStart(6)}${C.reset} ${r}`);
1233
+ }
1234
+ }
1235
+ if (launchFilter.skippedTotal > 0) {
1236
+ lines.push(
1237
+ ` ${C.yellow}skipped${C.reset} ${launchFilter.skippedTotal} transcript(s) \u2014 ${launchSkipReason()}`
1238
+ );
1239
+ }
1240
+ lines.push("");
1241
+ for (const item of collected.slice(0, 20)) {
1242
+ const s = item.session;
1243
+ const repo = s.env?.gitRepo ? ` ${s.env.gitRepo}${s.env.gitBranch ? `@${s.env.gitBranch}` : ""}` : "";
1244
+ lines.push(
1245
+ ` ${C.dim}${s.id.slice(0, 8)}${C.reset} ${s.agent.type} ${s.startedAt ?? "?"} ${s.turns.length} turns${repo}`
1246
+ );
1247
+ }
1248
+ if (collected.length > 20) lines.push(` ${C.dim}\u2026 and ${collected.length - 20} more${C.reset}`);
1249
+ lines.push("");
1250
+ lines.push(`Run again without --dry-run to sync.`);
1251
+ output2 = lines.join("\n");
1252
+ }
1253
+ if (!opts.quiet) process.stdout.write(output2 + "\n");
1254
+ return {
1255
+ scanned: report.scanned,
1256
+ eligible: collected.length,
1257
+ synced: 0,
1258
+ rejected: [],
1259
+ // A dry run reads the spool (artifactPlan, above) to show what would
1260
+ // upload, but never ATTEMPTS an upload — and every drop reason
1261
+ // (missing blob, permanent 4xx, 14-day age-out) is discovered only
1262
+ // by attempting one, so nothing can be dropped this run either way.
1263
+ artifactsDropped: 0,
1264
+ blobsSent: 0,
1265
+ batches: batches.length,
1266
+ tier,
1267
+ url,
1268
+ dryRun: true,
1269
+ output: output2
1270
+ };
1271
+ }
1272
+ const fetchImpl = opts.fetchImpl ?? defaultFetch;
1273
+ const endpoint = new URL("/v1/agents/sync", url).toString();
1274
+ let synced = 0;
1275
+ let blobsSent = 0;
1276
+ let artifactsDropped = 0;
1277
+ const rejected = [];
1278
+ let watermarkMs = sinceMs;
1279
+ let shippedBatches = 0;
1280
+ const progress = (final = false) => {
1281
+ if (opts.quiet || opts.json || batches.length <= 1) return;
1282
+ const msg = ` batch ${shippedBatches}/${batches.length} \xB7 ${synced} session(s) synced`;
1283
+ if (process.stderr.isTTY) {
1284
+ process.stderr.write(`\r${msg}${final ? "\n" : ""}`);
1285
+ } else if (final || shippedBatches % 25 === 0) {
1286
+ process.stderr.write(`${msg}
1287
+ `);
1288
+ }
1289
+ };
1290
+ const failureContext = () => {
1291
+ if (opts.quiet || shippedBatches === 0) return;
1292
+ process.stderr.write(
1293
+ `
1294
+ ${C.yellow}!${C.reset} progress saved: ${synced} session(s) in ${shippedBatches}/${batches.length} batch(es) shipped before the failure \u2014 re-run ${C.dim}outerlayer sync${C.reset} to resume from there.
1295
+ `
1296
+ );
1297
+ };
1298
+ const sleepImpl = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1299
+ const queue = batches.map(
1300
+ (b) => ({ payload: b.payload, maxMtimeMs: b.maxMtimeMs, advance: true })
1301
+ );
1302
+ let requestSessionCap = Number.POSITIVE_INFINITY;
1303
+ while (queue.length > 0) {
1304
+ const batch = queue.shift();
1305
+ if (batch.payload.sessions.length > requestSessionCap) {
1306
+ const [a, b] = splitPayload(batch.payload);
1307
+ queue.unshift(
1308
+ { payload: a, maxMtimeMs: batch.maxMtimeMs, advance: false },
1309
+ { payload: b, maxMtimeMs: batch.maxMtimeMs, advance: batch.advance }
1310
+ );
1311
+ continue;
1312
+ }
1313
+ let response;
1314
+ let networkErr = "";
1315
+ for (let attempt = 1; attempt <= MAX_BATCH_ATTEMPTS; attempt += 1) {
1316
+ try {
1317
+ response = await fetchImpl(endpoint, {
1318
+ method: "POST",
1319
+ headers: {
1320
+ "content-type": "application/json",
1321
+ authorization: `Bearer ${apiKey}`,
1322
+ "x-outerlayer-app-id": appId
1323
+ },
1324
+ body: JSON.stringify(batch.payload)
1325
+ });
1326
+ networkErr = "";
1327
+ stats.contacted = true;
1328
+ } catch (err) {
1329
+ response = void 0;
1330
+ networkErr = String(err);
1331
+ }
1332
+ if (response && !RETRYABLE_STATUSES.has(response.status)) break;
1333
+ if (attempt === MAX_BATCH_ATTEMPTS) break;
1334
+ const retryAfterRaw = response?.headers.get("retry-after");
1335
+ const retryAfterMs2 = retryAfterRaw && /^\d+$/.test(retryAfterRaw) ? Number(retryAfterRaw) * 1e3 : 0;
1336
+ const delayMs = Math.max(retryAfterMs2, RETRY_BASE_DELAY_MS2 * 2 ** (attempt - 1));
1337
+ if (!opts.quiet) {
1338
+ const cause = response ? `HTTP ${response.status}` : "network error";
1339
+ process.stderr.write(
1340
+ `
1341
+ ${C.yellow}!${C.reset} batch ${shippedBatches + 1}/${batches.length} hit ${cause} \u2014 retrying in ${Math.round(delayMs / 1e3)}s (attempt ${attempt}/${MAX_BATCH_ATTEMPTS})
1342
+ `
1343
+ );
1344
+ }
1345
+ await sleepImpl(delayMs);
1346
+ }
1347
+ if (!response) {
1348
+ failureContext();
1349
+ throw new SyncTransportError(`network error reaching ${endpoint}: ${networkErr}`);
1350
+ }
1351
+ if (response.status === 401) {
1352
+ failureContext();
1353
+ throw new SyncTransportError(
1354
+ unauthorizedMessage(apiKey),
1355
+ 401
1356
+ );
1357
+ }
1358
+ if ((RETRYABLE_STATUSES.has(response.status) || response.status === 413) && batch.payload.sessions.length > 1) {
1359
+ const [a, b] = splitPayload(batch.payload);
1360
+ requestSessionCap = Math.min(requestSessionCap, Math.max(a.sessions.length, b.sessions.length));
1361
+ if (!opts.quiet) {
1362
+ process.stderr.write(
1363
+ `
1364
+ ${C.yellow}!${C.reset} batch keeps failing (HTTP ${response.status}) \u2014 splitting into ${a.sessions.length}+${b.sessions.length} session request(s)
1365
+ `
1366
+ );
1367
+ }
1368
+ queue.unshift(
1369
+ { payload: a, maxMtimeMs: batch.maxMtimeMs, advance: false },
1370
+ { payload: b, maxMtimeMs: batch.maxMtimeMs, advance: batch.advance }
1371
+ );
1372
+ continue;
1373
+ }
1374
+ if (!response.ok) {
1375
+ let detail = "";
1376
+ try {
1377
+ const text = await response.text();
1378
+ try {
1379
+ const body2 = JSON.parse(text);
1380
+ detail = body2.error?.message ?? "";
1381
+ const usage = body2.error?.details ?? body2.error;
1382
+ if (response.status === 429 && typeof usage?.currentCount === "number" && typeof usage?.limit === "number") {
1383
+ detail += ` (${usage.currentCount.toLocaleString()}/${usage.limit.toLocaleString()} units used this month)`;
1384
+ }
1385
+ } catch {
1386
+ detail = text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
1387
+ }
1388
+ } catch {
1389
+ }
1390
+ failureContext();
1391
+ throw new SyncTransportError(`sync failed (${response.status})${detail ? `: ${detail}` : ""}`, response.status);
1392
+ }
1393
+ const body = await response.json();
1394
+ const sentCount = batch.payload.sessions.length;
1395
+ const sessionRejectedCount = body.data.rejected.filter((r) => r.index >= 0).length;
1396
+ const accountedCount = body.data.accepted.length + sessionRejectedCount;
1397
+ if (accountedCount !== sentCount && !opts.quiet) {
1398
+ process.stderr.write(
1399
+ `
1400
+ ${C.yellow}!${C.reset} batch ${shippedBatches + 1}/${batches.length} accounting mismatch: sent ${sentCount}, accepted ${body.data.accepted.length}, rejected ${sessionRejectedCount}
1401
+ `
1402
+ );
1403
+ }
1404
+ synced += body.data.accepted.length;
1405
+ blobsSent += body.data.blobsStored;
1406
+ rejected.push(...body.data.rejected);
1407
+ stats.synced = synced;
1408
+ stats.pending = Math.max(0, collected.length - synced);
1409
+ if (batch.advance) {
1410
+ watermarkMs = Math.max(watermarkMs, Math.min(batch.maxMtimeMs, watermarkCeilingMs));
1411
+ writeWatermark(url, appId, watermarkMs, home);
1412
+ shippedBatches += 1;
1413
+ progress(shippedBatches === batches.length);
1414
+ }
1415
+ }
1416
+ const foldMs = Math.min(passableMaxMtimeMs, watermarkCeilingMs);
1417
+ if (foldMs > watermarkMs) {
1418
+ watermarkMs = foldMs;
1419
+ writeWatermark(url, appId, watermarkMs, home);
1420
+ }
1421
+ let artifactsUploaded;
1422
+ let artifactsHeld;
1423
+ if (combinedRecords.length > 0 || artifactPlan.held.length > 0) {
1424
+ const artifactEndpoint = new URL("/v1/artifacts", url).toString();
1425
+ const nowMs = Date.now();
1426
+ const state = artifactPlan.state;
1427
+ const failedOrDroppedThisRun = /* @__PURE__ */ new Set();
1428
+ let uploadedCount = 0;
1429
+ let heldCount = artifactPlan.held.length;
1430
+ let droppedCount = 0;
1431
+ const warn = (message) => {
1432
+ if (!opts.quiet) process.stderr.write(`${C.yellow}!${C.reset} ${message}
1433
+ `);
1434
+ };
1435
+ let unauthorized;
1436
+ for (const { record } of combinedRecords) {
1437
+ const name = sanitizeTerminal(record.filename);
1438
+ const holdReason = artifactHoldReason(record.sessionId);
1439
+ if (holdReason !== void 0) {
1440
+ heldCount += 1;
1441
+ warn(`artifact ${name}: held \u2014 ${holdReason}`);
1442
+ continue;
1443
+ }
1444
+ const existing = state.get(record.artifactId);
1445
+ const parsedEmittedMs = Date.parse(record.t);
1446
+ const emittedMsUsable = Number.isFinite(parsedEmittedMs) && parsedEmittedMs <= nowMs;
1447
+ const priorFirstSeenMs = existing !== void 0 ? Date.parse(existing.firstSeen) : NaN;
1448
+ const firstSeenMs = emittedMsUsable ? parsedEmittedMs : Number.isFinite(priorFirstSeenMs) ? priorFirstSeenMs : nowMs;
1449
+ const firstSeenIso = new Date(firstSeenMs).toISOString();
1450
+ if (unauthorized) {
1451
+ heldCount += 1;
1452
+ failedOrDroppedThisRun.add(record.artifactId);
1453
+ continue;
1454
+ }
1455
+ let blobData;
1456
+ try {
1457
+ blobData = readFileSync(join(artifactBlobsDir(home), record.sha256));
1458
+ } catch {
1459
+ state.set(record.artifactId, {
1460
+ state: "dropped",
1461
+ attempts: existing?.attempts ?? 0,
1462
+ firstSeen: firstSeenIso,
1463
+ sha256: record.sha256,
1464
+ reachedServer: true
1465
+ });
1466
+ failedOrDroppedThisRun.add(record.artifactId);
1467
+ warn(`artifact ${name}: blob missing from the spool \u2014 dropping the record`);
1468
+ droppedCount += 1;
1469
+ continue;
1470
+ }
1471
+ const owner = collected.find((c) => c.session.id === record.sessionId);
1472
+ const turnIndex = owner ? resolveArtifactTurnIndex(owner.session.turns ?? [], record.filename) : void 0;
1473
+ let replaces = record.replaces;
1474
+ if (record.replacesTargets === void 0 && replaces !== void 0) {
1475
+ const targetKnownUploaded = state.get(replaces)?.state === "uploaded";
1476
+ const targetTombstonedUnconfirmed = !targetKnownUploaded && isArtifactTombstoned(replaces, home);
1477
+ if (targetTombstonedUnconfirmed || failedOrDroppedThisRun.has(replaces)) replaces = void 0;
1478
+ }
1479
+ const request = {
1480
+ schemaVersion: 1,
1481
+ artifact: {
1482
+ clientArtifactId: record.artifactId,
1483
+ filename: record.filename,
1484
+ mediaType: record.mediaType,
1485
+ bytes: record.bytes,
1486
+ sha256: record.sha256,
1487
+ caption: record.caption,
1488
+ ...record.criterionId !== void 0 ? { criterionId: record.criterionId } : {},
1489
+ emittedAt: record.t,
1490
+ ...record.prNumber !== void 0 ? { prNumber: record.prNumber } : {},
1491
+ ...record.gitRepo !== void 0 ? { gitRepo: record.gitRepo } : {},
1492
+ ...record.gitBranch !== void 0 ? { gitBranch: record.gitBranch } : {},
1493
+ ...record.commitSha !== void 0 ? { commitSha: record.commitSha } : {},
1494
+ ...replaces !== void 0 ? { replaces } : {},
1495
+ ...record.replacesTargets !== void 0 ? { replacesTargets: record.replacesTargets } : {},
1496
+ session: { sessionId: record.sessionId, ...turnIndex !== void 0 ? { turnIndex } : {} }
1497
+ },
1498
+ blob: { data: blobData.toString("base64") }
1499
+ };
1500
+ let failure = "";
1501
+ let status;
1502
+ let errorCode = "";
1503
+ let serverMessage = "";
1504
+ try {
1505
+ const response = await fetchImpl(artifactEndpoint, {
1506
+ method: "POST",
1507
+ headers: {
1508
+ "content-type": "application/json",
1509
+ authorization: `Bearer ${apiKey}`,
1510
+ "x-outerlayer-app-id": appId
1511
+ },
1512
+ body: JSON.stringify(request)
1513
+ });
1514
+ if (!response.ok) {
1515
+ failure = `HTTP ${response.status}`;
1516
+ status = response.status;
1517
+ try {
1518
+ const body = JSON.parse(await response.text());
1519
+ if (typeof body.error?.code === "string") errorCode = body.error.code;
1520
+ if (typeof body.error?.message === "string") serverMessage = body.error.message;
1521
+ } catch {
1522
+ }
1523
+ }
1524
+ } catch (err) {
1525
+ failure = String(err);
1526
+ }
1527
+ if (!failure) {
1528
+ state.set(record.artifactId, {
1529
+ state: "uploaded",
1530
+ attempts: (existing?.attempts ?? 0) + 1,
1531
+ firstSeen: firstSeenIso,
1532
+ sha256: record.sha256,
1533
+ reachedServer: true
1534
+ });
1535
+ uploadedCount += 1;
1536
+ continue;
1537
+ }
1538
+ if (status === 401) {
1539
+ unauthorized = new SyncTransportError(
1540
+ unauthorizedMessage(apiKey),
1541
+ 401
1542
+ );
1543
+ heldCount += 1;
1544
+ failedOrDroppedThisRun.add(record.artifactId);
1545
+ continue;
1546
+ }
1547
+ if (status !== void 0 && status >= 400 && status < 500 && status !== 408 && status !== 429 && KNOWN_PERMANENT_ARTIFACT_ERROR_CODES.has(errorCode)) {
1548
+ const detail = serverMessage ? `: ${sanitizeTerminal(serverMessage)}` : "";
1549
+ const hint = errorCode === "unknown_replaces_target" ? " \u2014 re-emit with a corrected --replaces id, or without --replaces" : "";
1550
+ warn(`artifact ${name} rejected permanently (${failure}${detail})${hint} \u2014 dropping the record`);
1551
+ state.set(record.artifactId, {
1552
+ state: "dropped",
1553
+ attempts: (existing?.attempts ?? 0) + 1,
1554
+ firstSeen: firstSeenIso,
1555
+ sha256: record.sha256,
1556
+ reachedServer: !NEVER_REACHED_SERVER_ERROR_CODES.has(errorCode)
1557
+ });
1558
+ failedOrDroppedThisRun.add(record.artifactId);
1559
+ droppedCount += 1;
1560
+ continue;
1561
+ }
1562
+ const ageMs = nowMs - firstSeenMs;
1563
+ if (ageMs > ARTIFACT_RETRY_MAX_AGE_MS) {
1564
+ warn(`artifact ${name} (${failure}) is older than 14 days and still failing \u2014 dropping it`);
1565
+ state.set(record.artifactId, {
1566
+ state: "dropped",
1567
+ attempts: (existing?.attempts ?? 0) + 1,
1568
+ firstSeen: firstSeenIso,
1569
+ sha256: record.sha256,
1570
+ reachedServer: false
1571
+ });
1572
+ failedOrDroppedThisRun.add(record.artifactId);
1573
+ droppedCount += 1;
1574
+ continue;
1575
+ }
1576
+ warn(`artifact ${name} upload failed (${failure}) \u2014 will retry on the next sync`);
1577
+ state.set(record.artifactId, {
1578
+ state: "held",
1579
+ attempts: (existing?.attempts ?? 0) + 1,
1580
+ firstSeen: firstSeenIso,
1581
+ sha256: record.sha256,
1582
+ reachedServer: false
1583
+ });
1584
+ failedOrDroppedThisRun.add(record.artifactId);
1585
+ heldCount += 1;
1586
+ }
1587
+ if (uploadedCount + heldCount + droppedCount > 0) {
1588
+ if (uploadedCount > 0) stats.contacted = true;
1589
+ artifactsUploaded = uploadedCount;
1590
+ artifactsHeld = heldCount;
1591
+ artifactsDropped = droppedCount;
1592
+ if (!opts.quiet) {
1593
+ process.stderr.write(
1594
+ `${C.dim}artifacts${C.reset} ${uploadedCount} uploaded \xB7 ${heldCount} held \xB7 ${droppedCount} dropped
1595
+ `
1596
+ );
1597
+ }
1598
+ }
1599
+ const persisted = writeArtifactSpoolState(state, home);
1600
+ if (!persisted && !opts.quiet) {
1601
+ process.stderr.write(
1602
+ `${C.yellow}!${C.reset} failed to persist ${artifactsStatePath(home)} \u2014 this run's artifact progress may be re-attempted next sync (safe: uploads are idempotent on the client artifact id)
1603
+ `
1604
+ );
1605
+ }
1606
+ reclaimUnneededArtifactBlobs(home, neededArtifactShas(home, state));
1607
+ if (unauthorized) {
1608
+ failureContext();
1609
+ throw unauthorized;
1610
+ }
1611
+ }
1612
+ if (shippedBatches === batches.length && (opts.url || opts.apiKey || opts.appId)) {
1613
+ if (isLoopbackUrl(url)) {
1614
+ if (!opts.quiet) {
1615
+ process.stderr.write(
1616
+ `${C.dim} not saving ${new URL(url).host} as the default destination \u2014 pass the flags again next time, or edit ${cloudConfigPath(home)}${C.reset}
1617
+ `
1618
+ );
1619
+ }
1620
+ } else {
1621
+ writeCloudConfig(
1622
+ { url, apiKey: resolved.kind === "key" ? apiKey : void 0, appId: resolved.kind === "key" ? appId : void 0 },
1623
+ home
1624
+ );
1625
+ }
1626
+ }
1627
+ let output;
1628
+ if (opts.json) {
1629
+ output = JSON.stringify({
1630
+ scanned: report.scanned,
1631
+ eligible: collected.length,
1632
+ synced,
1633
+ rejected,
1634
+ blobsSent,
1635
+ artifactsDropped,
1636
+ tier,
1637
+ url,
1638
+ ...artifactsUploaded !== void 0 ? { artifactsUploaded, artifactsHeld } : {}
1639
+ });
1640
+ } else {
1641
+ const lines = [];
1642
+ const icon = rejected.length === 0 && artifactsDropped === 0 && launchStatusIsDecisive(launchSpool) ? `${C.green}\u2713${C.reset}` : `${C.yellow}!${C.reset}`;
1643
+ lines.push(
1644
+ `${icon} synced ${synced}/${collected.length} session(s) \u2192 ${new URL(url).host} ${C.dim}(tier ${tier}${blobsSent ? `, ${blobsSent} image(s)` : ""}${repoFilter.skippedTotal > 0 ? `, ${repoFilter.skippedTotal} skipped by repo filter` : ""}${launchFilter.skippedTotal > 0 ? `, ${launchFilter.skippedTotal} skipped \u2014 ${launchSkipReason()}` : ""})${C.reset}`
1645
+ );
1646
+ if (artifactsDropped > 0) {
1647
+ lines.push(
1648
+ `${C.yellow} ${artifactsDropped} artifact(s) dropped \u2014 bytes missing from the spool, refused permanently, or too old to keep retrying, so the evidence they were emitted for is NOT attached${C.reset}`
1649
+ );
1650
+ }
1651
+ if (collected.length === 0) {
1652
+ lines.push(
1653
+ launchSpool.unreadable === void 0 ? `${C.dim} nothing new since the last sync \u2014 use --all to re-send everything${C.reset}` : `${C.dim} fix the launch spool's permissions and sync again \u2014 nothing was passed over${C.reset}`
1654
+ );
1655
+ }
1656
+ for (const r of rejected.slice(0, 10)) {
1657
+ lines.push(` ${C.red}\u2717${C.reset} ${r.id ?? `#${r.index}`} ${C.dim}${r.reason}${C.reset}`);
1658
+ }
1659
+ if (rejected.length > 10) lines.push(` ${C.dim}\u2026 and ${rejected.length - 10} more rejects${C.reset}`);
1660
+ output = lines.join("\n");
1661
+ }
1662
+ if (!opts.quiet) process.stdout.write(output + "\n");
1663
+ writeHookExecWatermark(hookWatermarkOffset, home);
1664
+ pruneHookExecSpool({ home, consumedOffset: hookWatermarkOffset });
1665
+ return {
1666
+ scanned: report.scanned,
1667
+ eligible: collected.length,
1668
+ synced,
1669
+ rejected,
1670
+ blobsSent,
1671
+ artifactsDropped,
1672
+ batches: batches.length,
1673
+ tier,
1674
+ url,
1675
+ dryRun: false,
1676
+ watermarkMs,
1677
+ ...artifactsUploaded !== void 0 ? { artifactsUploaded, artifactsHeld } : {},
1678
+ output
1679
+ };
1680
+ }
1681
+ }
1682
+
1683
+ export { CHUNK_PART_TARGET_BYTES, LiveUploader, RETRYABLE_STATUSES, SyncConfigError, SyncTransportError, isLoopbackUrl, resolveTier, runSync, staleBuildRefusal, staleBuildWarning };