@vibe-commit/capture 0.7.0 → 0.9.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 (152) hide show
  1. package/README.md +238 -7
  2. package/dist/account_not_here.d.ts +15 -0
  3. package/dist/account_not_here.js +25 -0
  4. package/dist/account_not_here.js.map +1 -0
  5. package/dist/agents/claude_code.js +13 -0
  6. package/dist/agents/claude_code.js.map +1 -1
  7. package/dist/agents/codex_cli.js +11 -0
  8. package/dist/agents/codex_cli.js.map +1 -1
  9. package/dist/agents/cursor.js +7 -1
  10. package/dist/agents/cursor.js.map +1 -1
  11. package/dist/agents/registry.d.ts +7 -0
  12. package/dist/agents/registry.js +9 -0
  13. package/dist/agents/registry.js.map +1 -1
  14. package/dist/agents/types.d.ts +32 -0
  15. package/dist/agents/types.js.map +1 -1
  16. package/dist/bounded_id.d.ts +29 -0
  17. package/dist/bounded_id.js +42 -0
  18. package/dist/bounded_id.js.map +1 -0
  19. package/dist/codex_folders.d.ts +340 -0
  20. package/dist/codex_folders.js +1448 -0
  21. package/dist/codex_folders.js.map +1 -0
  22. package/dist/commands/auth.d.ts +7 -0
  23. package/dist/commands/auth.js +21 -0
  24. package/dist/commands/auth.js.map +1 -1
  25. package/dist/commands/backfill.d.ts +29 -7
  26. package/dist/commands/backfill.js +387 -109
  27. package/dist/commands/backfill.js.map +1 -1
  28. package/dist/commands/browser_credential.d.ts +4 -2
  29. package/dist/commands/browser_credential.js +5 -2
  30. package/dist/commands/browser_credential.js.map +1 -1
  31. package/dist/commands/connect.d.ts +10 -1
  32. package/dist/commands/connect.js +14 -9
  33. package/dist/commands/connect.js.map +1 -1
  34. package/dist/commands/connect_account.d.ts +135 -0
  35. package/dist/commands/connect_account.js +321 -0
  36. package/dist/commands/connect_account.js.map +1 -0
  37. package/dist/commands/connect_picker.js +204 -29
  38. package/dist/commands/connect_picker.js.map +1 -1
  39. package/dist/commands/device_credential.d.ts +3 -1
  40. package/dist/commands/device_credential.js +1 -0
  41. package/dist/commands/device_credential.js.map +1 -1
  42. package/dist/commands/finalize.d.ts +67 -0
  43. package/dist/commands/finalize.js +450 -114
  44. package/dist/commands/finalize.js.map +1 -1
  45. package/dist/commands/finish_mint.d.ts +28 -0
  46. package/dist/commands/finish_mint.js +16 -4
  47. package/dist/commands/finish_mint.js.map +1 -1
  48. package/dist/commands/scan.d.ts +28 -0
  49. package/dist/commands/scan.js +11 -0
  50. package/dist/commands/scan.js.map +1 -1
  51. package/dist/commands/status.d.ts +19 -1
  52. package/dist/commands/status.js +123 -14
  53. package/dist/commands/status.js.map +1 -1
  54. package/dist/commands/status_machine.d.ts +12 -3
  55. package/dist/commands/status_machine.js +111 -13
  56. package/dist/commands/status_machine.js.map +1 -1
  57. package/dist/commands/sync_reason.d.ts +17 -0
  58. package/dist/commands/sync_reason.js +25 -0
  59. package/dist/commands/sync_reason.js.map +1 -0
  60. package/dist/consent.d.ts +131 -3
  61. package/dist/consent.js +248 -14
  62. package/dist/consent.js.map +1 -1
  63. package/dist/copy/strings.d.ts +165 -4
  64. package/dist/copy/strings.js +190 -7
  65. package/dist/copy/strings.js.map +1 -1
  66. package/dist/folder_grants.d.ts +44 -0
  67. package/dist/folder_grants.js +101 -0
  68. package/dist/folder_grants.js.map +1 -0
  69. package/dist/git.d.ts +26 -4
  70. package/dist/git.js +82 -11
  71. package/dist/git.js.map +1 -1
  72. package/dist/grants_sync.d.ts +165 -0
  73. package/dist/grants_sync.js +289 -0
  74. package/dist/grants_sync.js.map +1 -0
  75. package/dist/grants_verb.d.ts +70 -0
  76. package/dist/grants_verb.js +121 -0
  77. package/dist/grants_verb.js.map +1 -0
  78. package/dist/hooks/entry.d.ts +30 -50
  79. package/dist/hooks/entry.js +601 -561
  80. package/dist/hooks/entry.js.map +1 -1
  81. package/dist/hooks/fire_lock.d.ts +31 -0
  82. package/dist/hooks/fire_lock.js +120 -12
  83. package/dist/hooks/fire_lock.js.map +1 -1
  84. package/dist/hooks/session_grants.d.ts +59 -0
  85. package/dist/hooks/session_grants.js +91 -0
  86. package/dist/hooks/session_grants.js.map +1 -0
  87. package/dist/hooks/session_start.d.ts +28 -7
  88. package/dist/hooks/session_start.js +55 -11
  89. package/dist/hooks/session_start.js.map +1 -1
  90. package/dist/index.js +22 -5
  91. package/dist/index.js.map +1 -1
  92. package/dist/machine_state.d.ts +44 -1
  93. package/dist/machine_state.js +75 -12
  94. package/dist/machine_state.js.map +1 -1
  95. package/dist/oauth/discovery.js +2 -1
  96. package/dist/oauth/discovery.js.map +1 -1
  97. package/dist/oauth/pkce.js +2 -1
  98. package/dist/oauth/pkce.js.map +1 -1
  99. package/dist/oauth/retire.d.ts +10 -0
  100. package/dist/oauth/retire.js +13 -0
  101. package/dist/oauth/retire.js.map +1 -1
  102. package/dist/paths.d.ts +54 -1
  103. package/dist/paths.js +88 -12
  104. package/dist/paths.js.map +1 -1
  105. package/dist/pending_revoke.d.ts +117 -0
  106. package/dist/pending_revoke.js +233 -0
  107. package/dist/pending_revoke.js.map +1 -0
  108. package/dist/pending_revoke_file.d.ts +55 -0
  109. package/dist/pending_revoke_file.js +74 -0
  110. package/dist/pending_revoke_file.js.map +1 -0
  111. package/dist/physical_path.d.ts +46 -0
  112. package/dist/physical_path.js +151 -0
  113. package/dist/physical_path.js.map +1 -0
  114. package/dist/picker.d.ts +82 -5
  115. package/dist/picker.js +183 -10
  116. package/dist/picker.js.map +1 -1
  117. package/dist/picker_checklist.d.ts +27 -3
  118. package/dist/picker_checklist.js +81 -6
  119. package/dist/picker_checklist.js.map +1 -1
  120. package/dist/policy.d.ts +24 -1
  121. package/dist/policy.js +30 -2
  122. package/dist/policy.js.map +1 -1
  123. package/dist/post.d.ts +55 -2
  124. package/dist/post.js +72 -22
  125. package/dist/post.js.map +1 -1
  126. package/dist/project.d.ts +89 -0
  127. package/dist/project.js +123 -0
  128. package/dist/project.js.map +1 -1
  129. package/dist/redact.d.ts +77 -2
  130. package/dist/redact.js +713 -47
  131. package/dist/redact.js.map +1 -1
  132. package/dist/session_targets.d.ts +713 -0
  133. package/dist/session_targets.js +2602 -0
  134. package/dist/session_targets.js.map +1 -0
  135. package/dist/spool.d.ts +12 -0
  136. package/dist/spool.js +29 -1
  137. package/dist/spool.js.map +1 -1
  138. package/dist/spool_union.d.ts +51 -0
  139. package/dist/spool_union.js +57 -0
  140. package/dist/spool_union.js.map +1 -0
  141. package/dist/state.d.ts +5 -0
  142. package/dist/state.js +19 -1
  143. package/dist/state.js.map +1 -1
  144. package/dist/system_message.d.ts +25 -2
  145. package/dist/system_message.js +29 -5
  146. package/dist/system_message.js.map +1 -1
  147. package/dist/text_tail.d.ts +8 -0
  148. package/dist/text_tail.js +19 -0
  149. package/dist/text_tail.js.map +1 -0
  150. package/dist/version.d.ts +1 -1
  151. package/dist/version.js +1 -1
  152. package/package.json +3 -3
@@ -31,24 +31,26 @@
31
31
  * the offer open no transcript, and nothing else is read from this one. Codex needs no check (its index already holds the
32
32
  * real `cwd`). Cursor records no folder at all (measured, STATIC), so its sessions are never uploaded and are counted instead.
33
33
  */
34
- import { closeSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
34
+ import { lstatSync, realpathSync } from "node:fs";
35
35
  import { basename, isAbsolute } from "node:path";
36
- import { admitsTranscript, DIALECTS } from "../agents/registry.js";
36
+ import { admitsTranscript, agentIsolatesRecords, DIALECTS } from "../agents/registry.js";
37
37
  import { BACKFILL, COMMANDS, SCAN, URLS, shortDate } from "../copy/index.js";
38
- import { grantsUnreadable, isCaptureAllowed } from "../consent.js";
38
+ import { captureStanding, grantsUnreadable, hasAnyConsent, isCaptureAllowed } from "../consent.js";
39
+ import { folderStandingOf } from "../folder_grants.js";
39
40
  import { loadCredential } from "../credential.js";
40
41
  import { EXIT } from "../exit.js";
41
- import { firstReflogSeconds, resolveRepoSlug } from "../git.js";
42
- import { confinementRoots } from "../hooks/entry.js";
42
+ import { firstReflogSeconds, onceStrictRepoSlug } from "../git.js";
43
+ import { FOLDER_REPOSITORY_FORBIDDEN } from "../policy.js";
43
44
  import { CAPTURE_NOT_APPROVED, ingestUrl } from "../post.js";
44
45
  import { resolveProjectKeys } from "../project.js";
46
+ import { TOP_LEVEL_CWD_ONLY, findSubagentTranscriptsChecked, groupKey, loadTargetRecord, scanTranscriptKeys, stopKeyOf, stopTarget } from "../session_targets.js";
45
47
  import { SESSION_LIVE_WINDOW_MS } from "../spool.js";
46
48
  import { fileState, loadSessionState } from "../state.js";
47
49
  import { glyph, paint, renderErrorBlock, tildePath, truncateMiddle, truncatePath, wrap, WRAP_COLUMNS } from "../term.js";
48
50
  import { writeLines } from "./context.js";
49
51
  import { credentialProblem } from "./credential_problem.js";
50
52
  import { authCommandFor, channelCommand, connectCommandFor } from "../install.js";
51
- import { closeSession, ingestUrlUnavailableBlock } from "./finalize.js";
53
+ import { closeSession, closeSpecOf, ingestUrlUnavailableBlock, resolveSessionTargets } from "./finalize.js";
52
54
  import { scanMachine } from "./scan.js";
53
55
  import { renderVerbHelp } from "../verb_help.js";
54
56
  /** A transcript larger than this on disk is not offered (the 50 MiB policy cap). Sub-agent streams are not capped. */
@@ -155,14 +157,150 @@ export function planBackfill(ctx, scanFn = scanMachine) {
155
157
  repos.push({ slug: repo.slug, candidates });
156
158
  total += candidates.length;
157
159
  }
160
+ const parent = planParentSessions(ctx, scanned);
161
+ total += parent.length;
158
162
  return {
159
163
  repos,
164
+ parent,
160
165
  total,
161
166
  cursorSessions,
162
167
  overSize,
163
168
  noReflog: [...noReflog.entries()].map(([path, sessions]) => ({ path, sessions })).sort((a, b) => (a.path < b.path ? -1 : 1)),
164
169
  };
165
170
  }
171
+ /**
172
+ * The sessions run from folders outside every repository, minus those already fully delivered. ⛔ Opens no transcript. Offered only
173
+ * where this machine consents to anything at all. Only a dialect that can isolate records (Claude Code and Codex; see `isolatesContentRecords`):
174
+ * Cursor records no folder, so it is not offered. A session whose target
175
+ * record says it is settled with every target's ledger at its end is done; one that touched nothing chosen is re-checked each run
176
+ * (cheap, and a later grant may change the answer), which the offer says plainly.
177
+ */
178
+ function planParentSessions(ctx, scanned) {
179
+ const nowMs = (ctx.now ?? (() => new Date()))().getTime();
180
+ if (!hasAnyConsent(ctx.home))
181
+ return [];
182
+ const out = [];
183
+ for (const folder of scanned.folders ?? []) {
184
+ for (const member of folder.members ?? []) {
185
+ // ⛔ C28: only a dialect that can isolate records (`isolatesContentRecords`) is offered here: a parent-folder session is judged by the
186
+ // repos its records name, and a dialect that cannot keep one repo's copy free of another's would send every repo it named the
187
+ // others' prompts and output. Such a session is uploaded, if at all, by the in-repo path, to the repo it ended in.
188
+ if (!agentIsolatesRecords(member.agent))
189
+ continue;
190
+ const found = statFile(member.session.path);
191
+ if (found === null || found.size > MAX_SESSION_BYTES)
192
+ continue;
193
+ const sessionId = sessionIdOf(member.agent, member.session.path);
194
+ if (sessionId === null)
195
+ continue;
196
+ if (parentDone(ctx.home, ctx.env, sessionId, member.session.path, found.size, nowMs - found.mtimeMs >= SESSION_LIVE_WINDOW_MS))
197
+ continue;
198
+ out.push({
199
+ agent: member.agent,
200
+ sessionId,
201
+ path: member.session.path,
202
+ dir: folder.path,
203
+ mtimeMs: found.mtimeMs,
204
+ startedMs: member.session.startedMs ?? member.session.mtimeMs,
205
+ size: found.size,
206
+ });
207
+ }
208
+ }
209
+ return out.sort((a, b) => a.mtimeMs - b.mtimeMs || (a.path < b.path ? -1 : 1));
210
+ }
211
+ /**
212
+ * Has this target's ledger sent EVERY stream of the session to its end (C29 ii)? The main stream to `mainEof` and each delegated file to
213
+ * its own size, each capped at `sizeCap[fileKey]` when the target is a folder's (a folder never sends past what was scanned).
214
+ */
215
+ function streamsSentTo(state, mainEof, subs, sizeCap) {
216
+ if (fileState(state, "main").sentOffset < mainEof)
217
+ return false;
218
+ for (const sub of subs) {
219
+ const to = sizeCap === undefined ? sub.size : Math.min(sub.size, sizeCap[sub.fileKey] ?? 0);
220
+ if (fileState(state, sub.fileKey).sentOffset < to)
221
+ return false;
222
+ }
223
+ return true;
224
+ }
225
+ /**
226
+ * Nothing a backfill could add for this session, under TODAY's consent and the file's CURRENT size (C18 #9, #10). Needs no transcript
227
+ * read: it is decided from the session's target record and its ledgers.
228
+ *
229
+ * - discovery is settled (the whole transcript was read and no root is waiting);
230
+ * - every repo root the record names that is GRANTED NOW has a target whose ledger has sent to the current end of the file and
231
+ * holds nothing back. A repo granted after the session was last uploaded has no target yet, so the session is offered again,
232
+ * and the run then uploads only the repos that still owe bytes;
233
+ * - the folder decision still holds: a folder target is done only when the scan reached the current end AND its ledger sent that
234
+ * far (bytes appended since are offered, not skipped for ever), and a session that named no repository at all whose launch
235
+ * folder is granted now but has no folder target yet is not done.
236
+ */
237
+ function parentDone(home, env, sessionId, transcriptPath, size, quiet) {
238
+ const record = loadTargetRecord(home, sessionId);
239
+ if (record === null || !record.settled)
240
+ return false;
241
+ // ⛔ C29 (i): THE SCAN MUST HAVE SEEN EVERY STREAM AS IT IS NOW. `settled` says the streams that were scanned were read to their end;
242
+ // it says nothing about a delegated file that appeared, or a file that GREW, since. Each stream's cursor remembers the size it
243
+ // scanned (`size`), so a stream whose file is a different size now has unread bytes, and a stream with no cursor at all was never read.
244
+ // A directory that cannot be LISTED is not an empty one (C17 #2): the delegated streams are unknown, so the session is not done.
245
+ const listing = findSubagentTranscriptsChecked(home, env, transcriptPath);
246
+ if (listing.listFailed)
247
+ return false;
248
+ const subs = listing.found;
249
+ let scanCurrent = record.scan.main?.size === size;
250
+ for (const sub of subs)
251
+ if (record.scan[sub.fileKey]?.size !== sub.size)
252
+ scanCurrent = false;
253
+ // The one exception is C21 #3: a FOLDER target the server refused for good can never be sent, so bytes appended after the refusal do
254
+ // not bring the session back (they could only ever be sent under that same folder).
255
+ const folderRefused = record.targets.some((t) => t.kind === "folder" && record.stopped[stopKeyOf(t)] !== undefined);
256
+ if (!scanCurrent && !folderRefused)
257
+ return false;
258
+ // ⛔ C29 (ii), the same rule for what was SENT: every stream of the target (main and each delegated file), not main alone.
259
+ const sentToEnd = (target) => {
260
+ const state = loadSessionState(home, { repoKey: target.ledgerKey, sessionId });
261
+ // ⛔ C35 (v): A QUIET SESSION IS NOT DONE UNTIL IT IS SEALED. An upload made while the transcript was recent or running leaves the target
262
+ // unfinalized; once the file has been quiet for the live window `uploadParent` would seal it, so the session must be offered again
263
+ // for that. (A session still running is not asked to seal: `quiet` is false.)
264
+ return state.endHold === null && streamsSentTo(state, size, subs, undefined) && (state.finalized !== null || !quiet);
265
+ };
266
+ const owed = new Set();
267
+ for (const info of Object.values(record.roots)) {
268
+ if (info.state !== "repo")
269
+ continue;
270
+ if (captureStanding(home, { worktree: info.toplevel, consent: info.commonDir }, info.slug) !== "granted")
271
+ continue;
272
+ owed.add(groupKey(info));
273
+ }
274
+ for (const key of owed) {
275
+ const target = record.targets.find((t) => t.kind === "repo" && t.groupKey === key);
276
+ // A target the server refused for good (C3: `record.stopped`) owes nothing: it can never be sent, and `status` names it. Without
277
+ // this the session would be offered again on every run and then reported with the wrong reason.
278
+ if (target !== undefined && record.stopped[stopKeyOf(target)] !== undefined)
279
+ continue;
280
+ if (target === undefined || !sentToEnd(target))
281
+ return false;
282
+ }
283
+ const noRepoNamed = Object.keys(record.roots).length === 0 && record.unresolved.length === 0 && !record.oversize;
284
+ const folder = record.targets.find((t) => t.kind === "folder");
285
+ if (folder !== undefined) {
286
+ if (record.stopped[stopKeyOf(folder)] !== undefined)
287
+ return true;
288
+ if (noRepoNamed && folderStandingOf(home, folder.folder ?? "") === "granted") {
289
+ return record.scan.main?.offset === size && sentToEnd(folder);
290
+ }
291
+ return true;
292
+ }
293
+ if (noRepoNamed && record.launchFolder !== null) {
294
+ try {
295
+ if (folderStandingOf(home, realpathSync(record.launchFolder)) === "granted")
296
+ return false;
297
+ }
298
+ catch {
299
+ // A launch folder that is gone cannot be a folder target.
300
+ }
301
+ }
302
+ return true;
303
+ }
166
304
  function candidateOf(member, slug, sessionId, found) {
167
305
  return {
168
306
  agent: member.agent,
@@ -224,21 +362,42 @@ export function renderOffer(plan, options) {
224
362
  .map((id) => `${SCAN.agent[id]} ${counts[id]}`)
225
363
  .join(" · ");
226
364
  const folders = plan.repos.map((repo) => folderRows(repo));
227
- const widestRight = Math.max(...plan.repos.map(countsOf).map((text) => text.length), ...folders.flatMap((rows) => rows.map((row) => agentsOf(row.counts).length)));
365
+ const widestRight = Math.max(0, ...plan.repos.map(countsOf).map((text) => text.length), ...folders.flatMap((rows) => rows.map((row) => agentsOf(row.counts).length)));
228
366
  // The column the right-hand text may start at without passing 80.
229
367
  const limit = WRAP_COLUMNS - widestRight;
230
- const slugWidth = Math.max(8, Math.min(SLUG_MAX, ...[Math.max(...plan.repos.map((repo) => repo.slug.length))], limit - ROW_INDENT - COLUMN_GAP));
368
+ const slugWidth = Math.max(8, Math.min(SLUG_MAX, ...[Math.max(0, ...plan.repos.map((repo) => repo.slug.length))], limit - ROW_INDENT - COLUMN_GAP));
231
369
  const pathCap = Math.max(PATH_MIN, limit - FOLDER_INDENT - 2);
232
370
  const pathOf = (dir) => truncatePath(tildePath(dir, home), pathCap);
233
- const longestPath = Math.max(...folders.flatMap((rows) => rows.map((row) => pathOf(row.dir).length)));
371
+ const longestPath = Math.max(0, ...folders.flatMap((rows) => rows.map((row) => pathOf(row.dir).length)));
234
372
  const column = Math.max(ROW_INDENT + slugWidth + COLUMN_GAP, FOLDER_INDENT + longestPath + 2);
235
- const lines = ["", ...wrap(BACKFILL.offerHeading(plan.repos.length, options.standalone !== false), 2), ""];
373
+ const lines = [""];
374
+ if (plan.repos.length > 0)
375
+ lines.push(...wrap(BACKFILL.offerHeading(plan.repos.length, options.standalone !== false), 2), "");
236
376
  for (const [index, repo] of plan.repos.entries()) {
237
377
  lines.push(`${" ".repeat(ROW_INDENT)}${paint(colour, "text", `${truncateMiddle(repo.slug, slugWidth).padEnd(column - ROW_INDENT)}${countsOf(repo)}`)}`);
238
378
  for (const row of folders[index]) {
239
379
  lines.push(`${" ".repeat(FOLDER_INDENT)}${paint(colour, "muted", `${pathOf(row.dir).padEnd(column - FOLDER_INDENT)}${agentsOf(row.counts)}`)}`);
240
380
  }
241
381
  }
382
+ const parent = plan.parent ?? [];
383
+ if (parent.length > 0) {
384
+ // R4: sessions run from folders outside a repository. Their repositories are not known until the transcript is read, after the
385
+ // yes, so this block says what will be CHECKED and what the check can find: never a promise of an upload.
386
+ if (plan.repos.length > 0)
387
+ lines.push("");
388
+ lines.push(...wrap(BACKFILL.parentHeading(parent.length), 2), "");
389
+ const byDir = new Map();
390
+ for (const candidate of parent)
391
+ byDir.set(candidate.dir, (byDir.get(candidate.dir) ?? 0) + 1);
392
+ const rows = [...byDir.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
393
+ const parentCap = Math.max(PATH_MIN, WRAP_COLUMNS - ROW_INDENT - COLUMN_GAP - BACKFILL.parentRowCounts(99999).length);
394
+ const shown = rows.map(([dir, count]) => ({ path: truncatePath(tildePath(dir, home), parentCap), count }));
395
+ const width = Math.max(...shown.map((row) => row.path.length));
396
+ for (const row of shown) {
397
+ lines.push(`${" ".repeat(ROW_INDENT)}${paint(colour, "text", `${row.path.padEnd(width + COLUMN_GAP)}${BACKFILL.parentRowCounts(row.count)}`)}`);
398
+ }
399
+ lines.push("", ...wrap(BACKFILL.parentNote, ROW_INDENT).map((line) => paint(colour, "muted", line)));
400
+ }
242
401
  const notOffered = notOfferedLines(plan, colour, home);
243
402
  if (notOffered.length > 0)
244
403
  lines.push("", ...notOffered);
@@ -246,73 +405,26 @@ export function renderOffer(plan, options) {
246
405
  return lines;
247
406
  }
248
407
  // ---------------------------------------------------------------------------
249
- // The folder check — exception 1. Everything between the markers is the ONLY code that reads a transcript.
408
+ // The folder check — exception 1. It no longer reads a transcript itself.
250
409
  // ---------------------------------------------------------------------------
251
- // folder-check:begin
252
410
  /**
253
- * The `cwd` of the transcript's first record that has one. ⛔ ONE VALUE. The record is parsed to read its top-level `cwd`
254
- * and nothing else is looked at, kept, returned or sent; a line that does not mention `"cwd"` is not parsed at all.
255
- * `none` (no record carries one in the first `FOLDER_CHECK_MAX_BYTES`) and `unreadable` (the file cannot be opened) both
256
- * fail closed — the session is skipped — but they are different facts and are told apart.
411
+ * The `cwd` of the transcript's first record that has one. ⛔ ONE VALUE, and ONE READER: this is a call to
412
+ * `scanTranscriptKeys` (`session_targets.ts`, R13 · D15 · A), the only code that reads a field out of a transcript, with
413
+ * the spec narrowed to a top-level `cwd` and nothing else, so the 10-24 one-value reading of D60 §D6 exception 1 is exactly
414
+ * what backfill's own check still does. `none` (no record carries one in the first `FOLDER_CHECK_MAX_BYTES`) and
415
+ * `unreadable` (the file cannot be opened) both fail closed — the session is skipped — but they are different facts and
416
+ * are told apart.
257
417
  */
258
418
  export function firstRecordedCwd(path) {
259
- let fd;
260
- try {
261
- fd = openSync(path, "r");
262
- }
263
- catch {
264
- return { kind: "unreadable" };
265
- }
266
- try {
267
- const block = Buffer.alloc(64 * 1024);
268
- let pending = Buffer.alloc(0);
269
- let readBytes = 0;
270
- while (readBytes < FOLDER_CHECK_MAX_BYTES) {
271
- const n = readSync(fd, block, 0, block.length, readBytes);
272
- if (n === 0)
273
- break;
274
- readBytes += n;
275
- pending = Buffer.concat([pending, block.subarray(0, n)]);
276
- for (let end = pending.indexOf(0x0a); end >= 0; end = pending.indexOf(0x0a)) {
277
- const line = pending.subarray(0, end).toString("utf8");
278
- pending = pending.subarray(end + 1);
279
- if (!line.includes('"cwd"'))
280
- continue;
281
- const cwd = cwdOfLine(line);
282
- if (cwd !== null)
283
- return { kind: "found", cwd };
284
- }
285
- }
286
- const last = cwdOfLine(pending.toString("utf8"));
287
- return last === null ? { kind: "none" } : { kind: "found", cwd: last };
288
- }
289
- catch {
419
+ const scan = scanTranscriptKeys(path, 0, FOLDER_CHECK_MAX_BYTES, TOP_LEVEL_CWD_ONLY, {
420
+ first: true,
421
+ includeUnterminatedTail: true,
422
+ });
423
+ if (scan.kind === "unreadable")
290
424
  return { kind: "unreadable" };
291
- }
292
- finally {
293
- try {
294
- closeSync(fd);
295
- }
296
- catch {
297
- // Nothing useful to do.
298
- }
299
- }
425
+ const found = scan.hits.find((hit) => hit.kind === "cwd");
426
+ return found === undefined ? { kind: "none" } : { kind: "found", cwd: found.value };
300
427
  }
301
- function cwdOfLine(line) {
302
- if (!line.includes('"cwd"'))
303
- return null;
304
- try {
305
- const record = JSON.parse(line);
306
- if (record === null || typeof record !== "object" || Array.isArray(record))
307
- return null;
308
- const cwd = record.cwd;
309
- return typeof cwd === "string" && cwd !== "" ? cwd : null;
310
- }
311
- catch {
312
- return null;
313
- }
314
- }
315
- // folder-check:end
316
428
  /**
317
429
  * Does the folder the session recorded still resolve to the clone it was matched to?
318
430
  *
@@ -357,6 +469,10 @@ function classify(outcome) {
357
469
  return { kind: "uploaded" };
358
470
  case "never-captured":
359
471
  return { kind: "failed", reason: BACKFILL.refusedByServer };
472
+ case "lock-lost":
473
+ // C25: another writer took the session while this run held it; nothing more was written. The session is "busy", as when the
474
+ // lock could not be had at all, and a later run uploads what is left.
475
+ return { kind: "skipped", reason: BACKFILL.parentBusy };
360
476
  case "not-delivered":
361
477
  case "subagents-held": {
362
478
  const delivery = outcome.delivery;
@@ -415,32 +531,175 @@ async function uploadOne(run, candidate) {
415
531
  if (!folderMatches(recorded.cwd, candidate.commonDir))
416
532
  return { kind: "skipped", reason: BACKFILL.folderDiffers };
417
533
  }
418
- let slug = run.slugs.get(candidate.clone);
419
- if (slug === undefined) {
420
- slug = resolveRepoSlug(keys.worktree);
421
- run.slugs.set(candidate.clone, slug);
422
- }
423
534
  const quiet = ctx.now().getTime() - found.mtimeMs >= SESSION_LIVE_WINDOW_MS;
424
- const outcome = await run.close({
425
- home: ctx.home,
426
- env: ctx.env,
427
- nodeVersion: ctx.nodeVersion,
428
- agentId: candidate.agent,
429
- cwd: candidate.dir,
430
- url: run.url,
431
- credential: run.credential,
432
- projectKey: keys.worktree,
433
- redactionRoots: confinementRoots(keys),
434
- repoSlug: slug,
435
- sessionId: candidate.sessionId,
436
- transcriptPath: candidate.path,
437
- eof: found.size,
438
- // Sealed only when the transcript has been quiet for 30 minutes and it is not the session running this command.
439
- seal: quiet && !run.running.has(candidate.sessionId),
440
- allowNeverCaptured: true,
441
- completeSubagents: true,
442
- });
443
- return classify(outcome);
535
+ // ⛔ EVERY BACKFILL UPLOAD GOES THROUGH THE ONE RESOLVER, UNDER THE SESSION'S ONE WRITER LOCK (C18 #3). This path used to call
536
+ // `closeSession` with `keys.worktree` directly: no lock (it could interleave with a live hook on the same session) and no sticky
537
+ // ledger (a session the hook already adopted another work tree's ledger for would start a second ledger and re-send from 0). The
538
+ // resolver gives the target this repo already has for the session, ledger and slug included.
539
+ //
540
+ // ⛔ NO FALLBACK UPLOAD (C21 #1). When the resolver returns no target for this repo, the session is SKIPPED: nothing is sent and no
541
+ // ledger is written. It returns none when the strict slug failed (git timed out: the old fallback answered `local:<hash>`, a WRONG
542
+ // repository identity that a later hook would then adopt along with its ledger) or when the root was deferred (a fresh ledger at
543
+ // `keys.worktree` is the double send that deferral exists to prevent). The next run, with git answering, uploads it properly.
544
+ const releases = [];
545
+ try {
546
+ // `turnEndCwd` is the folder the OFFER verified this session belongs to (the agent's own index, or the first `cwd` the transcript
547
+ // records): the evidence that put it in this repo's offer, handed to the resolver so a transcript with no `cwd` of its own still
548
+ // resolves. It cannot name a repo the offer did not (the candidate is this repo's) and it is no fallback identity.
549
+ const resolved = await resolveSessionTargets({ home: ctx.home, env: ctx.env, nowMs: ctx.now().getTime(), turnEndCwd: candidate.dir }, candidate.sessionId, candidate.path, { keys, slugOf: onceStrictRepoSlug(keys.worktree) }, releases);
550
+ if (resolved.kind === "busy")
551
+ return { kind: "skipped", reason: BACKFILL.parentBusy };
552
+ const heartbeat = resolved.heartbeat;
553
+ // This repo's target: one of its work trees, or any work tree of the same git common dir (a session recorded in a LINKED work tree
554
+ // belongs to the clone the offer was made for).
555
+ const own = resolved.targets.find((target) => target.kind === "repo" &&
556
+ (target.worktrees.includes(keys.worktree) || target.ledgerKey === keys.worktree || target.commonDir === keys.consent));
557
+ if (own === undefined)
558
+ return { kind: "failed", reason: BACKFILL.noRepoIdentity };
559
+ const spec = closeSpecOf(own, resolved.scan, resolved);
560
+ const outcome = await run.close({
561
+ home: ctx.home,
562
+ env: ctx.env,
563
+ nodeVersion: ctx.nodeVersion,
564
+ agentId: candidate.agent,
565
+ cwd: candidate.dir,
566
+ url: run.url,
567
+ credential: run.credential,
568
+ projectKey: spec.projectKey,
569
+ redactionRoots: spec.redactionRoots,
570
+ repoSlug: spec.repoSlug,
571
+ sessionId: candidate.sessionId,
572
+ transcriptPath: candidate.path,
573
+ eof: found.size,
574
+ // Sealed only when the transcript has been quiet for 30 minutes and it is not the session running this command.
575
+ seal: quiet && !run.running.has(candidate.sessionId),
576
+ allowNeverCaptured: true,
577
+ completeSubagents: true,
578
+ worktrees: spec.worktrees,
579
+ ...(spec.isolation === undefined ? {} : { isolation: spec.isolation }),
580
+ heartbeat,
581
+ });
582
+ return classify(outcome);
583
+ }
584
+ finally {
585
+ for (const release of releases) {
586
+ try {
587
+ release();
588
+ }
589
+ catch {
590
+ // A lock that cannot be released ages out by itself.
591
+ }
592
+ }
593
+ }
594
+ }
595
+ /**
596
+ * One parent-folder session (R4): find, by the resolver `finalize` and the hook use, every granted repository it touched (and the
597
+ * folder, when it touched none and the folder is granted), then close it once per target through `closeSession`, so a
598
+ * multi-repo session uploads once per granted repo with each repo's own redaction scope. ⛔ The first read of this transcript
599
+ * happens HERE, after the yes and after the consent check, one session at a time.
600
+ */
601
+ async function uploadParent(run, candidate) {
602
+ const { ctx } = run;
603
+ const found = statFile(candidate.path);
604
+ if (found === null)
605
+ return { kind: "skipped", reason: BACKFILL.fileGone };
606
+ if (found.size > MAX_SESSION_BYTES)
607
+ return { kind: "skipped", reason: BACKFILL.overSize };
608
+ if (!admitsTranscript(ctx.home, ctx.env, candidate.path))
609
+ return { kind: "skipped", reason: BACKFILL.outsideAgentFolder };
610
+ // ⛔ NOTHING IS READ without some consent on the machine (`vibecommit off` everywhere since the offer leaves nothing to find).
611
+ if (!hasAnyConsent(ctx.home))
612
+ return { kind: "skipped", reason: BACKFILL.turnedOff };
613
+ const releases = [];
614
+ try {
615
+ const resolved = await resolveSessionTargets({ home: ctx.home, env: ctx.env, nowMs: ctx.now().getTime(), turnEndCwd: null }, candidate.sessionId, candidate.path, null, releases);
616
+ if (resolved.kind === "busy")
617
+ return { kind: "skipped", reason: BACKFILL.parentBusy };
618
+ const heartbeat = resolved.heartbeat;
619
+ if (resolved.targets.length === 0)
620
+ return { kind: "skipped", reason: BACKFILL.parentTouchedNothing };
621
+ const quiet = ctx.now().getTime() - found.mtimeMs >= SESSION_LIVE_WINDOW_MS;
622
+ const results = [];
623
+ for (const target of resolved.targets) {
624
+ const spec = closeSpecOf(target, resolved.scan, resolved);
625
+ // ⛔ A target that is already delivered to the end of what this run would send, and already sealed when this run would seal
626
+ // (or would not seal), owes nothing (C18 #9): a repo chosen LATER brings the session back into the offer, and only the repos
627
+ // that still owe bytes are uploaded. Re-closing the others would send each an empty final request for nothing.
628
+ const sealing = quiet && !run.running.has(candidate.sessionId);
629
+ const toEof = spec.sizeCap === undefined ? found.size : Math.min(found.size, spec.sizeCap.main ?? 0);
630
+ const ledger = loadSessionState(ctx.home, { repoKey: spec.projectKey, sessionId: candidate.sessionId });
631
+ // ⛔ C29 (ii): ONLY WHEN EVERY STREAM IS AT ITS END. Main alone said "delivered" for a session whose delegated files (new since, or
632
+ // never sent) still owed bytes, and the target was skipped with them unsent.
633
+ // A listing that FAILED (EACCES, EIO) is "unknown", never "no delegated streams": the target is not skipped, and `run.close` handles it.
634
+ const subListing = findSubagentTranscriptsChecked(ctx.home, ctx.env, candidate.path);
635
+ if (!subListing.listFailed &&
636
+ ledger.endHold === null &&
637
+ streamsSentTo(ledger, toEof, subListing.found, spec.sizeCap) &&
638
+ (ledger.finalized !== null || !sealing)) {
639
+ results.push({ kind: "uploaded" });
640
+ continue;
641
+ }
642
+ const outcome = await run.close({
643
+ home: ctx.home,
644
+ env: ctx.env,
645
+ nodeVersion: ctx.nodeVersion,
646
+ agentId: candidate.agent,
647
+ cwd: candidate.dir,
648
+ url: run.url,
649
+ credential: run.credential,
650
+ projectKey: spec.projectKey,
651
+ redactionRoots: spec.redactionRoots,
652
+ repoSlug: spec.repoSlug,
653
+ sessionId: candidate.sessionId,
654
+ transcriptPath: candidate.path,
655
+ eof: spec.sizeCap === undefined ? found.size : Math.min(found.size, spec.sizeCap.main ?? 0),
656
+ seal: quiet && !run.running.has(candidate.sessionId),
657
+ allowNeverCaptured: true,
658
+ completeSubagents: true,
659
+ worktrees: spec.worktrees,
660
+ ...(spec.sizeCap === undefined ? {} : { sizeCap: spec.sizeCap }),
661
+ ...(spec.isolation === undefined ? {} : { isolation: spec.isolation }),
662
+ heartbeat,
663
+ });
664
+ // C25: the lock was taken from us. Stop BEFORE the next target's write.
665
+ if (outcome.kind === "lock-lost") {
666
+ results.push(classify(outcome));
667
+ break;
668
+ }
669
+ // C3: the server refused THIS folder target for good. Stopped and recorded; it does not fail the session or the run.
670
+ if ("delivery" in outcome &&
671
+ outcome.delivery.kind === "attempted" &&
672
+ outcome.delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
673
+ // C35 (iii): the stop is a write, so only while the session is still ours (the 403 is reported either way).
674
+ if (heartbeat())
675
+ stopTarget(ctx.home, candidate.sessionId, target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
676
+ results.push({ kind: "skipped", reason: BACKFILL.parentFolderRefused });
677
+ continue;
678
+ }
679
+ const result = classify(outcome);
680
+ results.push(result.kind === "held" ? { kind: "held", slug: target.slug } : result);
681
+ }
682
+ // One answer for the session: a stop of the run wins, then a hold, then a failure; else it uploaded if anything did.
683
+ return (results.find((r) => r.kind === "rejected") ??
684
+ results.find((r) => r.kind === "stopped") ??
685
+ results.find((r) => r.kind === "held") ??
686
+ results.find((r) => r.kind === "failed") ??
687
+ // C25: a lock lost part-way leaves a target unsent, so the session is not "uploaded" however many targets went first. It is
688
+ // busy: a later run finds what is still owed (`parentDone` reads the ledgers) and sends it.
689
+ results.find((r) => r.kind === "skipped" && r.reason === BACKFILL.parentBusy) ??
690
+ results.find((r) => r.kind === "uploaded") ??
691
+ results[0]);
692
+ }
693
+ finally {
694
+ for (const release of releases) {
695
+ try {
696
+ release();
697
+ }
698
+ catch {
699
+ // A lock that cannot be released ages out by itself.
700
+ }
701
+ }
702
+ }
444
703
  }
445
704
  const newTally = () => ({
446
705
  uploaded: 0,
@@ -581,31 +840,41 @@ async function upload(ctx, plan, connection, deps, signal) {
581
840
  close: deps.closeSession ?? closeSession,
582
841
  readCwd: deps.readCwd ?? firstRecordedCwd,
583
842
  running: runningSessionIds(ctx.env),
584
- slugs: new Map(),
585
843
  };
586
- const column = ROW_INDENT + Math.min(SLUG_MAX, Math.max(...plan.repos.map((repo) => repo.slug.length))) + COLUMN_GAP;
587
- const repoLine = (repo, text) => `${" ".repeat(ROW_INDENT)}${truncateMiddle(repo.slug, column - ROW_INDENT - COLUMN_GAP).padEnd(column - ROW_INDENT)}${text}`;
844
+ const parent = plan.parent ?? [];
845
+ const names = [...plan.repos.map((repo) => repo.slug), ...(parent.length > 0 ? [BACKFILL.parentLabel] : [])];
846
+ const column = ROW_INDENT + Math.min(SLUG_MAX, Math.max(...names.map((name) => name.length))) + COLUMN_GAP;
847
+ const nameLine = (name, text) => `${" ".repeat(ROW_INDENT)}${truncateMiddle(name, column - ROW_INDENT - COLUMN_GAP).padEnd(column - ROW_INDENT)}${text}`;
848
+ /** A repo's sessions, then the parent-folder group. A repo whose owner has not approved capture is held whole; the parent group is not (its targets differ per session). */
849
+ const groups = [
850
+ ...plan.repos.map((repo) => ({ name: repo.slug, slug: repo.slug, items: repo.candidates })),
851
+ ...(parent.length > 0 ? [{ name: BACKFILL.parentLabel, slug: null, items: parent }] : []),
852
+ ];
588
853
  writeLines(ctx.stdout, [...wrap(BACKFILL.runHeading(plan.total), 2)]);
589
854
  let uploaded = 0;
590
855
  let skipped = 0;
591
856
  let held = 0;
592
857
  let failed = 0;
593
858
  const heldRepos = [];
594
- for (const repo of plan.repos) {
859
+ for (const group of groups) {
595
860
  const tally = newTally();
596
861
  let slowShown = false;
597
862
  const timer = setTimeout(() => {
598
863
  // A static line, overwritten once when the repo finishes — and only where an overwrite is possible (§13.8).
599
864
  slowShown = true;
600
- ctx.stdout.write(`${paint(ctx.colour, "muted", repoLine(repo, "…"))}`);
865
+ ctx.stdout.write(`${paint(ctx.colour, "muted", nameLine(group.name, "…"))}`);
601
866
  }, deps.slowAfterMs ?? SLOW_REPO_MS);
602
867
  let heldRepo = false;
603
868
  try {
604
- for (const candidate of repo.candidates) {
869
+ for (const candidate of group.items) {
605
870
  if (signal.aborted)
606
871
  break;
607
872
  // A repo whose owner has not approved capture is held whole: the rest of it is not even tried.
608
- const result = heldRepo ? { kind: "held" } : await uploadOne(run, candidate);
873
+ const result = heldRepo && group.slug !== null
874
+ ? { kind: "held" }
875
+ : "clone" in candidate
876
+ ? await uploadOne(run, candidate)
877
+ : await uploadParent(run, candidate);
609
878
  if (result.kind === "uploaded")
610
879
  tally.uploaded += 1;
611
880
  else if (result.kind === "skipped")
@@ -614,7 +883,10 @@ async function upload(ctx, plan, connection, deps, signal) {
614
883
  bump(tally.failed, result.reason);
615
884
  else if (result.kind === "held") {
616
885
  tally.held += 1;
617
- heldRepo = true;
886
+ if (group.slug !== null)
887
+ heldRepo = true;
888
+ else if (result.slug !== undefined)
889
+ heldRepos.push(result.slug);
618
890
  }
619
891
  else {
620
892
  // `stopped` (the server asked to try later, or could not be reached) and `rejected` (the credential was refused)
@@ -639,10 +911,10 @@ async function upload(ctx, plan, connection, deps, signal) {
639
911
  skipped += sumOf(tally.skipped);
640
912
  failed += sumOf(tally.failed);
641
913
  held += tally.held;
642
- if (tally.held > 0)
643
- heldRepos.push(repo.slug);
914
+ if (tally.held > 0 && group.slug !== null)
915
+ heldRepos.push(group.slug);
644
916
  const role = tally.held > 0 || tally.stopped ? "warn" : "text";
645
- const line = paint(ctx.colour, role, repoLine(repo, tallyLine(tally)));
917
+ const line = paint(ctx.colour, role, nameLine(group.name, tallyLine(tally)));
646
918
  // Overwrite the `…` line in place on a terminal; elsewhere it stays and the result prints beneath it.
647
919
  ctx.stdout.write(slowShown && ctx.colour ? `\r${line}\n` : `${slowShown ? "\n" : ""}${line}\n`);
648
920
  if (tally.stopped)
@@ -705,8 +977,14 @@ const NOTHING_NOT_LISTED = {
705
977
  * user did not choose is dropped by `planBackfill` however it got into `repos`; so is a clone with no reflog, which the plan asks
706
978
  * git about itself.
707
979
  */
708
- export async function backfillForConnect(ctx, repos, deps = {}) {
709
- const result = await runBackfill(ctx, { ...deps, fromConnect: true, scanMachine: () => ({ repos, notListed: NOTHING_NOT_LISTED }) });
980
+ export async function backfillForConnect(ctx, repos,
981
+ /** The folders outside every repository `connect`'s own scan found: their sessions are the parent-folder offer (R4). */
982
+ folders = [], deps = {}) {
983
+ const result = await runBackfill(ctx, {
984
+ ...deps,
985
+ fromConnect: true,
986
+ scanMachine: () => ({ repos, folders, notListed: NOTHING_NOT_LISTED }),
987
+ });
710
988
  switch (result.end) {
711
989
  case "declined":
712
990
  case "nothing":