@vibe-commit/capture 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +36 -1
  2. package/dist/agent_hooks.js +2 -2
  3. package/dist/cli.d.ts +35 -0
  4. package/dist/cli.js +412 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/backfill.js +19 -2
  7. package/dist/commands/backfill.js.map +1 -1
  8. package/dist/commands/finalize.d.ts +37 -1
  9. package/dist/commands/finalize.js +129 -29
  10. package/dist/commands/finalize.js.map +1 -1
  11. package/dist/commands/status.js +7 -0
  12. package/dist/commands/status.js.map +1 -1
  13. package/dist/commands/status_machine.js +5 -0
  14. package/dist/commands/status_machine.js.map +1 -1
  15. package/dist/commands/upload_worker.d.ts +65 -0
  16. package/dist/commands/upload_worker.js +337 -0
  17. package/dist/commands/upload_worker.js.map +1 -0
  18. package/dist/copy/strings.d.ts +25 -8
  19. package/dist/copy/strings.js +43 -9
  20. package/dist/copy/strings.js.map +1 -1
  21. package/dist/drain.d.ts +68 -0
  22. package/dist/drain.js +234 -0
  23. package/dist/drain.js.map +1 -0
  24. package/dist/firing.d.ts +169 -0
  25. package/dist/firing.js +334 -0
  26. package/dist/firing.js.map +1 -0
  27. package/dist/hooks/entry.d.ts +171 -26
  28. package/dist/hooks/entry.js +337 -508
  29. package/dist/hooks/entry.js.map +1 -1
  30. package/dist/hooks/fire_lock.d.ts +36 -87
  31. package/dist/hooks/fire_lock.js +40 -99
  32. package/dist/hooks/fire_lock.js.map +1 -1
  33. package/dist/hooks/post_commit.d.ts +2 -0
  34. package/dist/hooks/post_commit.js +7 -3
  35. package/dist/hooks/post_commit.js.map +1 -1
  36. package/dist/hooks/session_start.d.ts +18 -0
  37. package/dist/hooks/session_start.js +29 -0
  38. package/dist/hooks/session_start.js.map +1 -1
  39. package/dist/identity.d.ts +32 -0
  40. package/dist/identity.js +38 -0
  41. package/dist/identity.js.map +1 -0
  42. package/dist/index.d.ts +3 -17
  43. package/dist/index.js +59 -450
  44. package/dist/index.js.map +1 -1
  45. package/dist/paths.d.ts +18 -0
  46. package/dist/paths.js +31 -0
  47. package/dist/paths.js.map +1 -1
  48. package/dist/pending_prune.d.ts +2 -2
  49. package/dist/pending_prune.js +2 -2
  50. package/dist/policy.d.ts +17 -0
  51. package/dist/policy.js +7 -0
  52. package/dist/policy.js.map +1 -1
  53. package/dist/post.d.ts +52 -4
  54. package/dist/post.js +59 -21
  55. package/dist/post.js.map +1 -1
  56. package/dist/replay.d.ts +172 -0
  57. package/dist/replay.js +302 -0
  58. package/dist/replay.js.map +1 -0
  59. package/dist/session_targets.d.ts +12 -0
  60. package/dist/session_targets.js +6 -1
  61. package/dist/session_targets.js.map +1 -1
  62. package/dist/spool.d.ts +44 -16
  63. package/dist/spool.js +174 -33
  64. package/dist/spool.js.map +1 -1
  65. package/dist/spool_union.d.ts +18 -2
  66. package/dist/spool_union.js +23 -2
  67. package/dist/spool_union.js.map +1 -1
  68. package/dist/state.js +21 -14
  69. package/dist/state.js.map +1 -1
  70. package/dist/stdin.d.ts +2 -0
  71. package/dist/stdin.js +13 -0
  72. package/dist/stdin.js.map +1 -0
  73. package/dist/uploader_status.d.ts +34 -0
  74. package/dist/uploader_status.js +25 -0
  75. package/dist/uploader_status.js.map +1 -0
  76. package/dist/uploader_view.d.ts +39 -0
  77. package/dist/uploader_view.js +106 -0
  78. package/dist/uploader_view.js.map +1 -0
  79. package/dist/verbs.d.ts +11 -0
  80. package/dist/verbs.js +13 -0
  81. package/dist/verbs.js.map +1 -0
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +2 -2
@@ -33,33 +33,36 @@
33
33
  * own red-proof in `test/credential.test.ts`. Do not read one as evidence for
34
34
  * the other.
35
35
  */
36
- import { closeSync, existsSync, openSync, readSync, rmSync, statSync, utimesSync, writeSync, } from "node:fs";
36
+ import { closeSync, existsSync, openSync, readSync, statSync, writeSync, } from "node:fs";
37
+ import { spawn } from "node:child_process";
37
38
  import { basename, dirname } from "node:path";
38
39
  import { zstdCompressSync } from "node:zlib";
39
40
  import { grantsUnreadable, hasAnyConsent, isCaptureAllowed, isLocalSlug, normalizeSlug } from "../consent.js";
40
- import { endingSoon, noteCredentialExpiry } from "../credential_meta.js";
41
+ import { endingSoon, metaFor } from "../credential_meta.js";
41
42
  import { loadCredential } from "../credential.js";
42
43
  import { EXIT } from "../exit.js";
43
- import { headRef, onceRepoSlug, onceStrictRepoSlug } from "../git.js";
44
+ import { headRef, onceRepoSlug } from "../git.js";
44
45
  import { redactSpan, rulesForTranscript } from "../redact.js";
45
46
  import { authCommandFor, connectCommandFor } from "../install.js";
46
- import { installOnce, sessionStartPayload } from "./session_start.js";
47
- import { credentialFingerprint, deliver, ingestUrl } from "../post.js";
48
- import { FOLDER_REPOSITORY_FORBIDDEN, markSkipped } from "../policy.js";
49
- import { dialectIsolates, evidenceSpecFor, findSubagentTranscripts, isolationOf, listTargetRecords, pastCursorOf, redactionScopeFor, resolveTargets, stopKeyOf, stopTarget, } from "../session_targets.js";
47
+ import { installForWorkerTarget, installOnce, sessionStartPayload } from "./session_start.js";
48
+ import { CAPTURE_NOT_APPROVED, credentialFingerprint, deliver, ingestUrl } from "../post.js";
49
+ import { FOLDER_REPOSITORY_FORBIDDEN, markSkipped, replayDecision } from "../policy.js";
50
+ import { dialectIsolates, evidenceSpecFor, findSubagentTranscripts, isolationOf, loadTargetRecord, pastCursorOf, redactionScopeFor, stopTarget, } from "../session_targets.js";
50
51
  import { readSpoolUnion } from "../spool_union.js";
51
- import { fileState, loadSessionState, pruneDeliveredEndHold, saveSessionState, withFileState } from "../state.js";
52
- import { SESSION_END_EVENT, settleDelayMs, settledSize, } from "./session_end.js";
52
+ import { fileState, loadSessionState, saveSessionState, withFileState } from "../state.js";
53
53
  import { admitsTranscript, agentForTranscript, canonicalHookEvent, dialectFor, } from "../agents/registry.js";
54
- import { claimFiring, claimSessionWriter, fireLockPath, SESSION_SCOPE, STALE_MARGIN_MS } from "./fire_lock.js";
55
- import { announcedSubagentFileKey, subagentFileKey, } from "../paths.js";
54
+ import { FIRING_VERSION, MAX_FIRING_SUB_FILES, recordFiring, liveAtOf, liveStampKeys, stampLive } from "../firing.js";
55
+ import { identityDigest } from "../identity.js";
56
+ import { readUploaderStatus } from "../uploader_status.js";
57
+ import { uploaderAlive } from "./fire_lock.js";
58
+ import { announcedSubagentFileKey, sessionFileStem, sessionLivePath, sessionStatePath, subagentFileKey, } from "../paths.js";
56
59
  import { resolveProjectKeys } from "../project.js";
57
60
  export { confinementRoots } from "../project.js";
58
61
  import { startSpawnBudget } from "../spawn_budget.js";
59
62
  import { turnStart } from "../pending_bound.js";
60
63
  import { recordInferredSkipped } from "../inferred_tally.js";
61
64
  import { pruneOrphanPending } from "../pending_prune.js";
62
- import { pendingModeFor, promotePending, pruneExpiredInferred, readPending } from "../spool.js";
65
+ import { SESSION_LIVE_WINDOW_MS, pendingModeFor, promotePending, pruneExpiredInferred, readPending } from "../spool.js";
63
66
  import { meetsNodeFloor } from "../runtime.js";
64
67
  import { renderNotice } from "../system_message.js";
65
68
  /**
@@ -128,107 +131,6 @@ const MIN_SUBAGENT_BUDGET_MS = 400;
128
131
  * and a session that spawned 27 delegated agents will fire far more than four.
129
132
  */
130
133
  const MAX_SUBAGENT_FILES = 8;
131
- /**
132
- * What `stampGaps` gets to keep, carved OUT of the send budget on `SessionEnd`
133
- * — `CR-182`.
134
- *
135
- * ## The defect, and why it is "starved" rather than "never runs"
136
- *
137
- * `stampGaps` is called AFTER the send loop, and the watchdog fires at
138
- * `budgetMs` regardless. The loop's own arithmetic does leave it something: each
139
- * attempt takes 0.6 of what REMAINS, so the last one starts with at least
140
- * `MIN_SUBAGENT_BUDGET_MS` and returns with 0.4 of that — 160 ms — still on the
141
- * clock. But that 160 ms is measured from `startedAt`, which is the settle, and
142
- * **the watchdog is measured from `runHook`**. Everything before the settle —
143
- * the stdin read, the consent gate's `git rev-parse`, the credential load — is
144
- * time the loop never sees and the watchdog already spent. `CR-126` measured a
145
- * comparable pre-entry interval at p99 392 ms idle and 184 ms under load, so the
146
- * 160 ms cushion is routinely already gone.
147
- *
148
- * MEASURED against the unmodified client (12 delegated files, hung ingest
149
- * server, `SessionEnd`): idle, every run stamped. Under CPU saturation, a
150
- * 2200 ms budget produced **one state cell and zero gaps** — the loop's last
151
- * send overshot and the process was cut off before it could record anything.
152
- * That is D56 §D8's quietly-short record, which is the one outcome this client
153
- * may not produce.
154
- *
155
- * ## Why 150 ms, and why a fixed number is honest here
156
- *
157
- * `stampGaps` is pure synchronous fs — one `loadSessionState` + `saveSessionState`
158
- * pair per undelivered stream, bounded by `MAX_SUBAGENT_FILES + 1` (9) — with no
159
- * network, no spawn and no timer in it. So unlike a send, its cost does not
160
- * depend on anything outside this process, and a fixed reserve is a real bound
161
- * rather than a guess dressed as one. 150 ms is ~17 ms per stream at the cap on
162
- * a machine where the whole 9-file loop measures under 5 ms, i.e. an order of
163
- * magnitude of margin over the effect — the same ratio `SESSION_END_SETTLE_DELAY_MS`
164
- * is set at, and small enough that it costs the send 90 ms of 2850 on the
165
- * default budget.
166
- *
167
- * ## ⛔ THE RESERVE WAS NOT ACTUALLY RESERVED UNTIL `CR-195/U2` — the two clocks
168
- * started at different instants
169
- *
170
- * Everything above this paragraph diagnosed the defect correctly and then
171
- * subtracted the reserve from the WRONG ORIGIN. `budgetMs - 150` is a bound on
172
- * the loop's own clock, which starts at `settleStart`; the watchdog's clock
173
- * starts in `runHook`, before stdin is read. So the loop was permitted to spend
174
- * until `budgetMs` *measured from the settle* — already past the watchdog's
175
- * deadline by exactly the pre-entry interval this docblock names — and the
176
- * watchdog then fired MID-SEND. `stampGaps` runs after the loop, so it did not
177
- * run at all, and the held spans stayed held.
178
- *
179
- * ⛔ **That is the G6 flake, and it was a real product defect rather than a
180
- * timing-sensitive assertion.** `test/session-end-gaps.test.ts` caught it as
181
- * `held: [[0, 39]]` where a stamped gap belonged: on `SessionEnd` — which has no
182
- * next invocation — a hole that is held rather than stamped is the final turn
183
- * recorded as delivered-so-far instead of as short. `sendBudgetMs` now takes the
184
- * elapsed time since `runHook` armed the watchdog and charges it, so the reserve
185
- * is measured against the deadline that can actually end the process.
186
- *
187
- * ⚠ **Since `CR-228` the reserve pays for a HOLD record too.** A held stream is
188
- * no longer stamped, but `SessionState.endHold` is written for it — and a hook
189
- * cut off before that write leaves the bytes held with nothing saying the
190
- * session ended owing them, which `status` cannot see. Same reserve, same cost.
191
- *
192
- * ⚠ **Still a mitigation, not a guarantee.** Enough scheduler pressure defeats
193
- * any fixed reserve, exactly as it defeats the watchdog itself — a timer cannot
194
- * fire while the event loop is blocked. What changed is that the reserve now
195
- * survives the ordinary case it was written for; before, the ordinary case is
196
- * precisely what consumed it.
197
- *
198
- * ⛔ Zero on every other event: `stampGaps` runs on `SessionEnd` alone, so
199
- * reserving elsewhere would shrink `Stop`'s send for a stamp that never happens.
200
- */
201
- const GAP_STAMP_RESERVE_MS = 150;
202
- /**
203
- * The budget the SENDS may spend — the whole budget, less the stamp's share.
204
- *
205
- * A function, and exported, for the reason `sendTimeoutMs` is: the carve-out is
206
- * the thing this task changes, and a wall-clock test cannot pin it. Every
207
- * discriminator that distinguishes reserve from no-reserve through a real
208
- * process turned out to slide with per-send overhead — a 149 ms window that
209
- * moves by 25 ms for every 10 ms of connect-and-compress cost — so pinning it
210
- * there would be tuning a constant to one machine. Here it is arithmetic.
211
- *
212
- * ⛔ `budgetMs` itself still governs the SETTLE and the WATCHDOG. Only the sends
213
- * are carved.
214
- */
215
- /**
216
- * ⛔ **TAKES THE RESOLVED EVENT, NEVER THE RAW `hook_event_name` (`CR-195`).**
217
- *
218
- * It used to take a `string`, and that is precisely how Cursor's `sessionEnd`
219
- * bought a full-budget send with no reserve: the comparison was against
220
- * `"SessionEnd"`, so an unrecognised spelling was `false` rather than an error.
221
- * `HookEventName | null` makes the resolution a thing the CALLER must already
222
- * have done — `null` is the honest "none of the three", and an unresolved wire
223
- * string is no longer expressible here at all.
224
- */
225
- export function sendBudgetMs(budgetMs, event, sinceHookStartMs = 0) {
226
- const reserve = event === SESSION_END_EVENT ? GAP_STAMP_RESERVE_MS : 0;
227
- // ⛔ FLOORED AT ZERO. A negative would reach `sendTimeoutMs`, whose own
228
- // `Math.max(0, budgetMs - consumedMs)` turns a negative budget into a POSITIVE
229
- // remaining — a blown clock reading as fresh time.
230
- return Math.max(0, budgetMs - sinceHookStartMs - reserve);
231
- }
232
134
  /**
233
135
  * Parse hook stdin. Claude Code supplies `{ session_id, transcript_path, cwd,
234
136
  * hook_event_name }` on stdin — NOT in the environment.
@@ -302,14 +204,6 @@ export function silenceStderr() {
302
204
  * truncated write is the silent-unconfigured failure DX3 exists to kill.
303
205
  */
304
206
  function finish(payload) {
305
- // ⛔ RELEASED HERE, AND HERE IS THE ONLY PLACE THAT COVERS EVERY EXIT
306
- // (`CR-195`/U4a). A `try/finally` around `hookBody` would look tidier and
307
- // would leak the lock on the one path that matters most: the WATCHDOG calls
308
- // this function directly, and the signal handlers do too, so neither unwinds
309
- // `hookBody` at all. A lock leaked there survives until it goes stale, and
310
- // until then it suppresses the next firing of that event — turning a guard
311
- // against duplicates into a guard against captures.
312
- releaseFiring();
313
207
  if (payload !== null) {
314
208
  try {
315
209
  writeSync(1, `${payload}\n`);
@@ -320,29 +214,6 @@ function finish(payload) {
320
214
  }
321
215
  process.exit(EXIT.ok);
322
216
  }
323
- /**
324
- * The firing this process claimed, if it claimed one.
325
- *
326
- * ⚠ **Module state, deliberately, and it is the narrow case that justifies it.**
327
- * This process handles exactly ONE hook invocation and then exits; `finish` is
328
- * its single exit point and is reached from paths that never return through
329
- * `hookBody`. Threading a claim out to those paths is not possible — the
330
- * watchdog holds no reference to the body's locals.
331
- */
332
- const heldClaims = [];
333
- /** Idempotent, and never throws: the hook contract forbids saying anything. Releases EVERY lock this process holds. */
334
- function releaseFiring() {
335
- const claims = heldClaims.splice(0, heldClaims.length);
336
- for (const claim of claims) {
337
- try {
338
- claim.release();
339
- }
340
- catch {
341
- // A lock we could not release goes stale on its own; the bound is derived
342
- // from the budget precisely so a lost release costs one invocation, once.
343
- }
344
- }
345
- }
346
217
  /**
347
218
  * Install every contract guard, then run the body.
348
219
  *
@@ -524,199 +395,216 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
524
395
  // `/cso` finding 2: a rejected override is a silent no-send, not a fallback to the default.
525
396
  if (url === null)
526
397
  return null;
527
- // `SessionEnd` SETTLES FIRST (D57 §DX7): every other event has a next invocation to pick up a lagging write; this one does
528
- // not. Resolved through the dialect (`CR-195`, D205): Cursor sends `sessionEnd`.
529
- const isSessionEnd = event === SESSION_END_EVENT;
530
- // --- TWO LOCKS, and they are different questions (O5). ---
398
+ // --- U6: THE HOOK RECORDS A FIRING AND RETURNS (plan §3.1). It never waits for the network again. ---
531
399
  //
532
- // 1. `claimFiring`: "is this exact firing already running?" ONE FIRING, ONE PROCESS (`CR-195`/U4a): claimed BEFORE the settle
533
- // so a duplicate costs nothing; keyed on the canonical event; the loser BOWS OUT. It lives beside the turn-end repo's state
534
- // exactly as before, or beside the turn-end folder when the turn ended outside any work tree. ⚠ `claimFiring` returns
535
- // `null` for two reasons (held by another, or no lock path at all) and only the first may bow out: an unlockable firing
536
- // FAILS OPEN, because bowing out of every invocation would be a total silent outage.
537
- // 2. `claimSessionWriter`: "is another writer touching THIS SESSION right now?": ONE LOCK PER `sessionId`, whatever repo each
538
- // writer stands in, serialising the target record, every ledger and every spool against Stop, PreCompact, SessionEnd,
539
- // `finalize` and `backfill`. A different event WAITS inside this hook's own budget and gives up with `null` (its bytes ride
540
- // the next firing or the idle drain), never holding the watchdog hostage.
541
- const fireKey = {
542
- repoKey: SESSION_SCOPE,
543
- sessionId: input.sessionId,
544
- event: event ?? input.eventName,
545
- };
546
- const lockable = fireLockPath(ctx.home, fireKey) !== null;
547
- const firing = claimFiring(ctx.home, fireKey, budgetMs);
548
- if (firing === null && lockable) {
549
- // Another process holds this exact firing. ⛔ Silent and exit 0: a `systemMessage` here would put a line in the user's turn
550
- // for a duplicate they did not cause. `finish` releases nothing because we hold nothing.
551
- return null;
400
+ // What used to happen here (claim the firing, wait for the session's writer lock, settle, resolve targets, promote, read the spool, deliver, stamp
401
+ // gaps, drain idle sessions) is the upload worker's now, replayed from the line this appends (`replay.ts`, `drain.ts`). The motivating incident: the
402
+ // hook's 3 s attempt timeout abandoned requests the server then applied, which forked versions and starved the VM.
403
+ //
404
+ // ⛔ The hook holds NO lock. Duplicate firings (Cursor fires one event twice) are two lines, and the second is a no-op by `replayDecision`.
405
+ const load = loadCredential({ env: ctx.env, home: ctx.home });
406
+ if (load.kind !== "ok") {
407
+ // Absent or unusable: write nothing and spawn nothing. The notice's old condition was `resolution.targets.length > 0`, which the hook no longer
408
+ // computes; it is now "the turn-end repo is allowed, or the session's own record already names a target". A parent-folder session with no record
409
+ // stays silent on its first turn, which is the quiet direction.
410
+ const named = turnEndAllowed || (loadTargetRecord(ctx.home, input.sessionId)?.targets.length ?? 0) > 0;
411
+ return named ? notice("not_connected") : null;
552
412
  }
553
- if (firing !== null)
554
- heldClaims.push(firing);
555
- const writer = await claimSessionWriter(ctx.home, input.sessionId, {
556
- staleAfterMs: budgetMs + STALE_MARGIN_MS,
557
- deadlineAtMs: hookStartedAt + Math.floor(budgetMs * WRITER_WAIT_FRACTION),
558
- wait,
559
- });
560
- if (writer === null)
561
- return null;
562
- heldClaims.push(writer);
563
- const settleFor = isSessionEnd ? settleDelayMs(ctx.env, budgetMs) : 0;
564
- // `CR-182`. Carved OUT of the budget, never added on top. ⛔ AND THE PRE-ENTRY COST IS CHARGED HERE (`CR-195/U2`, the G6
565
- // defect): the stdin read, the gate's spawn, the credential load AND NOW the writer-lock wait all happened between the
566
- // watchdog starting and this line, and the reserve must be measured against the clock that can actually end the process.
567
- const settleStart = Date.now();
568
- const sendBudget = sendBudgetMs(budgetMs, event, settleStart - hookStartedAt);
569
- const eof = settleFor > 0
570
- ? await settledSize(() => fileSize(input.transcriptPath), wait, settleFor)
571
- : fileSize(input.transcriptPath);
413
+ const eof = fileSize(input.transcriptPath);
572
414
  if (eof === null)
573
415
  return null;
574
- // MEASURED, not assumed to be `settleFor`: the wait can overshoot under load, and the budget cares about the time spent.
575
- const consumedMs = settleFor > 0 ? Date.now() - settleStart : 0;
576
- const resolution = resolveTargets({
577
- home: ctx.home,
578
- env: ctx.env,
579
- sessionId: input.sessionId,
580
- transcriptPath: input.transcriptPath,
581
- turnEndCwd: input.cwd,
582
- mode: isSessionEnd ? "end" : "turn",
583
- nowMs: Date.now(),
584
- // ⛔ The RESOLVER gets a STRICT thunk (C17 #13b): `slugOf` above falls back to `local:<hash>` on a timeout, and what the resolver is given
585
- // becomes the target's sticky identity. The consent gate keeps `slugOf`.
586
- ...(keys !== null && slugOf !== null ? { known: { keys, slugOf: onceStrictRepoSlug(keys.worktree) } } : {}),
416
+ const identity = identityDigest({ url, env: ctx.env });
417
+ // Liveness (plan §3.5): READ BEFORE STAMPING, so the worker can judge `pendingModeFor` as the hook saw it; then stamp `.live`, hook-only.
418
+ const record = loadTargetRecord(ctx.home, input.sessionId);
419
+ const stampKeys = liveStampKeys({
420
+ isolates: dialectIsolates(ctx.home, ctx.env, input.transcriptPath),
421
+ turnEnd: turnEndAllowed && keys !== null ? keys.worktree : null,
422
+ recordLedgerKeys: record === null ? [] : record.targets.map((target) => target.ledgerKey),
587
423
  });
588
- if (resolution.targets.length === 0) {
589
- // Nothing granted was named (R8 · D11 · B): the hook stays SILENT, as it always was for an unconsented repo. A repo or
590
- // folder the session named that nobody decided about is recorded BY NAME for `vibecommit status` to list.
591
- //
592
- // But D22 says ANY later firing drains idle sessions, and a session that names nothing granted (common in a parent folder)
593
- // is exactly the firing that has the whole deadline spare. So: load the credential SILENTLY (no notice, ever: this session
594
- // has nothing to say), and if it is usable, spend the leftover on others' owed bytes.
595
- const quiet = loadCredential({ env: ctx.env, home: ctx.home });
596
- if (quiet.kind === "ok") {
597
- try {
598
- await drainIdleSessions({
599
- ctx,
600
- url,
601
- credential: quiet.credential,
602
- currentSessionId: input.sessionId,
603
- settleStart,
604
- sendBudget,
605
- staleAfterMs: budgetMs + STALE_MARGIN_MS,
606
- });
607
- }
608
- catch {
609
- // A hook cannot report an error.
610
- }
424
+ const prior = {};
425
+ for (const key of stampKeys) {
426
+ const before = priorLivenessOf(ctx.home, { repoKey: key, sessionId: input.sessionId });
427
+ if (before !== undefined)
428
+ prior[key] = before;
429
+ }
430
+ const firedAt = Date.now();
431
+ // `slug` only when step 3 got a REAL one, never the `local:` fallback: what the worker passes the resolver becomes a target's sticky identity.
432
+ const turnEndSlug = keys !== null && slugOf !== null ? slugOf() : null;
433
+ const firing = {
434
+ v: FIRING_VERSION,
435
+ at: firedAt,
436
+ sid: input.sessionId,
437
+ event: input.eventName,
438
+ eof,
439
+ transcript: input.transcriptPath,
440
+ cwd: input.cwd,
441
+ ...(keys === null ? {} : { keys: { worktree: keys.worktree, consent: keys.consent } }),
442
+ ...(turnEndSlug === null || isLocalSlug(turnEndSlug) ? {} : { slug: turnEndSlug }),
443
+ agent: ctx.agentId,
444
+ id: identity,
445
+ prior,
446
+ ...(input.agentTranscriptPath === undefined ? {} : { announced: input.agentTranscriptPath }),
447
+ ...subSizesOf(ctx, input),
448
+ };
449
+ recordFiring(ctx.home, input.sessionId, firing);
450
+ // Appended FIRST, stamped second, lock read third: the worker releases THEN looks at the queue, so one side always sees the other.
451
+ for (const key of stampKeys)
452
+ stampLive(ctx.home, { repoKey: key, sessionId: input.sessionId }, firedAt);
453
+ // --- NOTICES come from the worker's status file (plan §3.7): the hook cannot see this firing's outcome, only earlier ones. ---
454
+ const status = readUploaderStatus(ctx.home, identity);
455
+ const payload = (() => {
456
+ // `fatal`: a hook that stays silent about a revoked credential captures nothing forever. QUIET DURING ROTATION (`11A`): only about the credential this
457
+ // hook is about to use; if `auth` has replaced the one the worker was rejected with, there is nothing to tell the user.
458
+ if (status?.rejected !== undefined && status.rejected.fingerprint === credentialFingerprint(load.credential)) {
459
+ return notice("credential_revoked", null, load.credential);
460
+ }
461
+ // `CR-234d` (12A): the worker remembered where the credential stands in the SIDECAR; from 14 days before the end, say so once per machine per day.
462
+ // It outranks the positive message: that one repeats daily, this one is the only way a person learns capture will stop.
463
+ const endsAt = endingSoon(metaFor(ctx.home, credentialFingerprint(load.credential)), firedAt);
464
+ if (endsAt !== null) {
465
+ const ending = notice("credential_ending", null, load.credential, endsAt);
466
+ if (ending !== null)
467
+ return ending;
468
+ }
469
+ if (firstStop?.state != null) {
470
+ const install = notice(firstStop.state, null, undefined, undefined, firstStop.cloneKey);
471
+ if (install !== null)
472
+ return install;
473
+ }
474
+ // `capture_confirmed`: one turn later, and only on a hook of the session the outcome NAMES, still claimed once per (repo, day). The copy says
475
+ // "captured this session", so showing it in another session, possibly in a declined repo, would be false and would use up the claim.
476
+ const stem = sessionFileStem(input.sessionId);
477
+ if (turnEndAllowed && keys !== null && status !== null && status.outcomes.some((o) => o.session === stem && o.class === "done:delivered")) {
478
+ return notice("capture_confirmed", keys.worktree);
611
479
  }
612
480
  return null;
481
+ })();
482
+ // Spawn the worker if none is alive for this identity. A failed spawn is silent and leaves the firing for the next hook.
483
+ if (!uploaderAlive(ctx.home, identity))
484
+ spawnWorker(ctx);
485
+ return payload;
486
+ }
487
+ /**
488
+ * U6b (F5, C33) — each sub-agent file's size NOW, for `Firing.sub`: what the worker may send of it when it replays this firing. The same two
489
+ * discoveries `deliverSubagents` makes (the dialect's glob and the announced path), so a stream the delivery would send has a ceiling here. Empty
490
+ * map when there are none (a file that appears later is the NEXT firing's). `{}` (no key) when the list is too long to record: uncapped, as before.
491
+ * Never throws: a hook must not.
492
+ */
493
+ function subSizesOf(ctx, input) {
494
+ try {
495
+ const sub = {};
496
+ for (const file of [...findSubagentFiles(ctx, input.transcriptPath), ...announcedSubagentFiles(ctx, input)])
497
+ sub[file.fileKey] = file.size;
498
+ return Object.keys(sub).length > MAX_FIRING_SUB_FILES ? {} : { sub };
613
499
  }
614
- const load = loadCredential({ env: ctx.env, home: ctx.home });
615
- switch (load.kind) {
616
- case "absent":
617
- return notice("not_connected");
618
- case "wrong-class":
619
- case "insecure-file":
620
- case "unreadable":
621
- // Interactive verbs print four different fixes for these (§13.6). A hook has one channel and a one-line budget, so they
622
- // collapse to the state whose fix, `vibecommit connect`, is the same for all four.
623
- return notice("not_connected");
624
- case "ok":
625
- break;
500
+ catch {
501
+ return {};
502
+ }
503
+ }
504
+ /**
505
+ * U6 — the liveness the hook read BEFORE it stamped (plan §3.4, P2 point 4), in `Firing.prior`'s three states: a number (the later of the state
506
+ * file's `liveAt` and the `.live` stamp), `null` (a state file exists with NO `liveAt`: what `finalize` and `backfill` leave), or `undefined` (no
507
+ * state file at all, which reads as `first-turn`). A plain number-or-omitted map would read a resumed session as `first-turn`.
508
+ */
509
+ function priorLivenessOf(home, key) {
510
+ const statePath = sessionStatePath(home, key);
511
+ if (statePath === null)
512
+ return undefined;
513
+ const stamped = liveAtOf(null, sessionLivePath(home, key));
514
+ if (!existsSync(statePath))
515
+ return stamped ?? undefined;
516
+ return liveAtOf(loadSessionState(home, key).liveAt, sessionLivePath(home, key));
517
+ }
518
+ /**
519
+ * U6 — spawn the detached worker (plan §3.9): `stdio: "ignore"` so the hook's pipes are not held open, `cwd: home` so it pins no worktree or temp dir, the
520
+ * hook's own environment so `VIBECOMMIT_TOKEN` and the ingest override reach it. Node's documented background pattern. `selfPath` is `process.argv[1]`,
521
+ * so both channels (npm and the plugin's vendored bin) respawn themselves. Never throws, never waits.
522
+ */
523
+ function spawnWorker(ctx) {
524
+ const self = ctx.selfPath;
525
+ if (self === undefined || self === "")
526
+ return;
527
+ try {
528
+ const child = spawn(process.execPath, [self, "__upload-worker"], { detached: true, stdio: "ignore", windowsHide: true, cwd: ctx.home, env: ctx.env });
529
+ child.on("error", () => {
530
+ // A worker that could not start leaves the firing for the next hook.
531
+ });
532
+ child.unref();
533
+ }
534
+ catch {
535
+ // Silent, per the hook contract.
626
536
  }
627
- // --- ONE SESSION, N TARGETS, ONE DEADLINE (A2). ---
628
- // Least-recently-sent first, so a target with a 20 MB backlog cannot starve the others forever: it spends the firing, its
629
- // `lastSentAt` moves, and the NEXT firing serves the one that waited. Each target's own sends still honour the shared
630
- // deadline (`deadlineAtMs`), so the whole firing stays inside the one budget.
537
+ }
538
+ /** Pure. The reason for one target from its delivery and where its main ledger stands against the `eof` it was asked to reach. */
539
+ export function targetReason(delivery, sentOffset, eof, stopped) {
540
+ if (stopped)
541
+ return { kind: "held", why: "folder-forbidden" };
542
+ switch (delivery.kind) {
543
+ case "stopped":
544
+ return { kind: "held", why: "stopped" };
545
+ case "no-repo":
546
+ return { kind: "never" };
547
+ case "nothing-to-send":
548
+ if (delivery.why === "lock-lost")
549
+ return { kind: "lock-lost" };
550
+ if (delivery.why === "read-failed" || delivery.why === "unwritable")
551
+ return { kind: "not-progressed", why: delivery.why };
552
+ if (delivery.why === "unterminated")
553
+ return { kind: "held", why: "unterminated" };
554
+ return sentOffset >= eof ? { kind: "caught-up" } : { kind: "not-progressed", why: "read-failed" };
555
+ case "attempted":
556
+ switch (delivery.disposition) {
557
+ case "fatal":
558
+ return { kind: "fatal" };
559
+ case "never":
560
+ return { kind: "never" };
561
+ case "later":
562
+ return delivery.detail?.code === CAPTURE_NOT_APPROVED ? { kind: "held", why: "capture-not-approved" } : { kind: "later" };
563
+ case "ok":
564
+ return sentOffset >= eof ? { kind: "caught-up" } : { kind: "more-owed" };
565
+ }
566
+ }
567
+ }
568
+ /**
569
+ * U4 — DELIVER ONE FIRING TO ITS RESOLVED TARGETS. The loop `hookBody` always ran inline, callable by the upload worker with a replay's
570
+ * as-of inputs and no wall clock. Behaviour for the inline hook is unchanged: `pace.kind === "hook"` and no `asOf` is the old code.
571
+ *
572
+ * Order and fairness are as before: least-recently-sent target first, so a 20 MB backlog on one cannot starve the rest.
573
+ */
574
+ export async function deliverFiring(opts) {
575
+ const { ctx, input, resolution, pace } = opts;
631
576
  const ordered = leastRecentlySentFirst(ctx.home, input.sessionId, resolution.targets);
632
577
  // ⛔ D95 STAYS, exactly as written, for the one case R14 protects: a session whose turns end in ONE granted work tree and
633
578
  // which names nothing else granted. Every other session (several targets, a target found only by evidence, a folder target)
634
579
  // has later firings that can finish it, so a shortfall at SessionEnd is OWED there, not written off (R19 · D22).
635
580
  const writeOffAtSessionEnd = ordered.length === 1 && ordered[0].kind === "repo" && resolution.turnEndSlug === normalizeSlug(ordered[0].slug);
636
- const deadline = settleStart + sendBudget;
581
+ const deadline = pace.kind === "hook" ? pace.settleStart + pace.sendBudget : Infinity;
637
582
  const results = [];
638
583
  for (const target of ordered) {
639
584
  const result = await deliverTarget({
640
585
  ctx,
641
586
  input,
642
- url,
643
- credential: load.credential,
587
+ url: opts.url,
588
+ credential: opts.credential,
644
589
  target,
645
- eof,
646
- event,
647
- isSessionEnd,
648
- settleStart,
649
- sendBudget,
650
- consumedMs: results.length === 0 ? consumedMs : Date.now() - settleStart,
590
+ eof: opts.eof,
591
+ event: opts.event,
592
+ isSessionEnd: opts.isSessionEnd,
593
+ pace: pace.kind === "hook" ? { ...pace, consumedMs: results.length === 0 ? pace.consumedMs : Date.now() - pace.settleStart } : pace,
651
594
  // A target reached with the deadline already spent sends nothing this firing but is still STAMPED at SessionEnd.
652
595
  skipSend: results.length > 0 && Date.now() >= deadline,
653
596
  owedAtSessionEnd: !writeOffAtSessionEnd,
654
597
  scanned: scannedOffsets(resolution.record.scan),
655
598
  record: resolution.record,
599
+ ...(opts.asOf === undefined ? {} : { asOf: opts.asOf }),
600
+ ...(opts.heartbeat === undefined ? {} : { heartbeat: opts.heartbeat }),
656
601
  });
657
602
  results.push(result);
603
+ if (result.reason.kind === "lock-lost")
604
+ break;
658
605
  }
659
- // The three classes are decided in `policy.ts` and applied in `post.ts`. Two of the four outcomes reach a human.
660
- //
661
- // `fatal`: a hook that stays silent about a revoked credential captures nothing forever. `later` and `never` say nothing: a
662
- // hook has one line per session and spending it on a condition that resolves itself next turn is the tool noise D57 §DX3
663
- // exists to avoid. `ok`: the fourth state (`CR-023d`), and its `(repo, day)` claim is what keeps it to once.
664
- //
665
- // With N targets the answer is ONE: any `fatal` wins (it is credential-wide by definition), else the first target that
666
- // delivered speaks. A per-target stop (C3) is never `fatal` and is never a notice: `status` names it.
667
- const fatal = results.find((r) => r.stopped === false && r.delivery.kind === "attempted" && r.delivery.disposition === "fatal");
668
- const payload = (() => {
669
- if (fatal !== undefined && fatal.delivery.kind === "attempted") {
670
- // ⛔ QUIET DURING ROTATION (`11A`). This hook loaded its credential before an `auth` replaced the file; its 401 is about a
671
- // credential that is already gone. If what is on disk NOW is a different credential, there is nothing to tell the user.
672
- const current = loadCredential({ env: ctx.env, home: ctx.home });
673
- if (current.kind === "ok" && credentialFingerprint(current.credential) !== credentialFingerprint(load.credential)) {
674
- return null;
675
- }
676
- return notice("credential_revoked", null, load.credential);
677
- }
678
- const ok = results.find((r) => r.delivery.kind === "attempted" && r.delivery.disposition === "ok");
679
- if (ok === undefined || ok.delivery.kind !== "attempted")
680
- return null;
681
- // `CR-234d` (12A): a 2xx carries where the credential stands (`X-Credential-Expires`). Remember it in the SIDECAR (never in
682
- // `credentials.json`, which a hook must not rewrite, R10) and, from 14 days before the end, say so once per machine per day.
683
- // It outranks the positive message: that one repeats daily, this one is the only way a person learns capture will stop.
684
- if (ok.delivery.credentialExpires != null) {
685
- const meta = noteCredentialExpiry(ctx.home, ctx.env, credentialFingerprint(load.credential), ok.delivery.credentialExpires, new Date());
686
- const endsAt = endingSoon(meta, Date.now());
687
- if (endsAt !== null) {
688
- const ending = notice("credential_ending", null, load.credential, endsAt);
689
- if (ending !== null)
690
- return ending;
691
- }
692
- }
693
- return ((firstStop?.state == null ? null : notice(firstStop.state, null, undefined, undefined, firstStop.cloneKey)) ??
694
- notice("capture_confirmed", ok.target.ledgerKey));
695
- })();
696
- // The leftover of the one deadline goes to sessions that have had no firing for 10 minutes (R19 · D22): never throws.
697
- try {
698
- await drainIdleSessions({
699
- ctx,
700
- url,
701
- credential: load.credential,
702
- currentSessionId: input.sessionId,
703
- settleStart,
704
- sendBudget,
705
- staleAfterMs: budgetMs + STALE_MARGIN_MS,
706
- });
707
- }
708
- catch {
709
- // A hook cannot report an error, and a drain that failed leaves every session exactly as it was.
710
- }
711
- return payload;
606
+ return results;
712
607
  }
713
- /**
714
- * How much of the hook's budget a firing may spend WAITING for another writer of the same session before it gives up. Most of it (0.8): a different event is serialised behind the holder, and the holder's own send can run to its timeout before it lets go, so a shorter
715
- * window would give up on exactly the overlapping Stop/SessionEnd case (`session-end-gaps` U4a). The
716
- * rest must still cover the settle, the send and (`SessionEnd`) the gap stamp. A waiter that gives up loses nothing: its bytes
717
- * ride the next firing, or the idle drain.
718
- */
719
- const WRITER_WAIT_FRACTION = 0.8;
720
608
  /**
721
609
  * Targets in the order they are served: the one that sent LEAST recently first (A2). `lastSentAt` is the latest successful send
722
610
  * across the target's ledger's streams, 0 for "never", so a target that has never been served goes first and a target that just
@@ -732,6 +620,23 @@ function leastRecentlySentFirst(home, sessionId, targets) {
732
620
  .sort((a, b) => a.at - b.at || a.index - b.index)
733
621
  .map((entry) => entry.target);
734
622
  }
623
+ /**
624
+ * U4 — `pendingModeFor`, answered from `firing.prior` (plan §3.4, P2 point 4). `prior` keeps `pendingModeFor`'s three outcomes:
625
+ * - a NUMBER is the stamp the hook read before it stamped: live within the window of `firing.at` is `first-turn`, older is `stale-own`;
626
+ * - an explicit `null` is a state file with NO `liveAt` (what `finalize` and `backfill` leave): `stale-own`;
627
+ * - an ABSENT key is no state file at hook time: `first-turn`. A key the hook did not record at all (a second work tree, a parent-folder
628
+ * repo on its first firing) falls back to `pendingModeFor` judged at `firing.at`, never `Date.now()`, which is safe because stamps arrive in
629
+ * firing order.
630
+ * A plain number-or-omitted map would read a resumed session as `first-turn` and keep a lingering-id line.
631
+ */
632
+ export function modeFromPrior(home, key, asOf) {
633
+ const recorded = asOf.prior[key.repoKey];
634
+ if (recorded === null)
635
+ return "stale-own";
636
+ if (recorded !== undefined)
637
+ return asOf.at - recorded <= SESSION_LIVE_WINDOW_MS ? "first-turn" : "stale-own";
638
+ return pendingModeFor(home, key, asOf.at);
639
+ }
735
640
  /**
736
641
  * Deliver ONE target of ONE session: what `hookBody` used to do inline for its single repo, now per target.
737
642
  *
@@ -744,17 +649,44 @@ function leastRecentlySentFirst(home, sessionId, targets) {
744
649
  * stream's offset can rewind the other's.
745
650
  */
746
651
  async function deliverTarget(opts) {
747
- const { ctx, input, url, credential, target, event, isSessionEnd, settleStart, sendBudget, consumedMs } = opts;
652
+ const { ctx, input, url, credential, target, event, isSessionEnd, pace, asOf, heartbeat } = opts;
653
+ // U5: a holder that lost its lock writes nothing, not even a promotion.
654
+ if (heartbeat !== undefined && !heartbeat()) {
655
+ return { target, delivery: { kind: "nothing-to-send", why: "lock-lost" }, subagents: { files: [], held: false, deliveries: [] }, stopped: false, reason: { kind: "lock-lost" } };
656
+ }
657
+ // U10: the worker (the only caller that passes `selfPath`) gives this repo its git hooks before the first send, the same once-per-clone plain write
658
+ // the hook's own `installOnce` makes. A commit made before it is missed either way; the next one is seen.
659
+ installForWorkerTarget({ env: ctx.env, home: ctx.home, selfPath: ctx.selfPath ?? "" }, target);
660
+ // The hook's clock, when it has one. The worker has none: `undefined` all the way down, and a fixed timeout in its place.
661
+ const hookClock = pace.kind === "hook" ? pace : null;
662
+ const deadlineAtMs = hookClock === null ? undefined : hookClock.settleStart + hookClock.sendBudget;
663
+ const requestTimeoutMs = hookClock === null ? pace.timeoutMs : sendTimeoutMs(hookClock.sendBudget, hookClock.consumedMs);
748
664
  // ⛔ A FOLDER stream delivers only up to what the resolver has SCANNED (P1, A-T3 requirement b): bytes appended between the scan
749
665
  // and this read were never checked for repo evidence, and a repo they name would end the folder rule. A repo target has no such
750
666
  // limit: its bytes are its own whatever else the session named.
751
667
  // C42: a REPO copy of a dialect that isolates records may deliver past the scan cursor. Every per-record decision there is exact (the record's own
752
668
  // cwd, the path rule, the always-withheld types); the two facts the checkpoint cannot hold are learned IN-SPAN, in order (`pastCursorOf`).
753
669
  const folderCap = target.kind === "folder" ? (opts.scanned ?? {}) : undefined;
754
- const eof = folderCap === undefined ? opts.eof : Math.min(opts.eof, folderCap.main ?? 0);
670
+ let eof = folderCap === undefined ? opts.eof : Math.min(opts.eof, folderCap.main ?? 0);
671
+ // ⛔ U4, TRAP 1 — A REPLAYED FIRING NEVER GOES TO `nextSpan` WITH AN `eof` BEHIND `sentOffset`: that reads as "the file was replaced" and
672
+ // resyncs the whole session from byte 0. Compare with the file's REAL size first (`replayDecision`): a rotation delivers to the real size,
673
+ // an already-delivered firing (a duplicate, or one older than a `finalize`) is a no-op.
674
+ let replayNoop = false;
675
+ if (asOf !== undefined) {
676
+ const real = fileSize(input.transcriptPath);
677
+ if (real === null) {
678
+ return { target, delivery: { kind: "nothing-to-send", why: "read-failed" }, subagents: { files: [], held: false, deliveries: [] }, stopped: false, reason: { kind: "not-progressed", why: "read-failed" } };
679
+ }
680
+ const sent = fileState(loadSessionState(ctx.home, { repoKey: target.ledgerKey, sessionId: input.sessionId }), "main").sentOffset;
681
+ const decision = replayDecision(sent, eof, real);
682
+ if (decision.kind === "noop")
683
+ replayNoop = true;
684
+ else
685
+ eof = decision.eof;
686
+ }
755
687
  const baseIsolation = isolationOf(target, opts.record);
756
688
  const isolation = opts.record !== undefined && opts.scanned !== undefined && dialectIsolates(ctx.home, ctx.env, input.transcriptPath)
757
- ? { ...baseIsolation, past: pastCursorOf(baseIsolation, opts.scanned, { deadlineAtMs: settleStart + sendBudget }) }
689
+ ? { ...baseIsolation, past: pastCursorOf(baseIsolation, opts.scanned, deadlineAtMs === undefined ? {} : { deadlineAtMs }) }
758
690
  : baseIsolation;
759
691
  const sessionId = input.sessionId;
760
692
  const ledgerKey = target.ledgerKey;
@@ -767,9 +699,15 @@ async function deliverTarget(opts) {
767
699
  // return value is NOT discarded, it feeds the durable `set_aside` tally.
768
700
  for (const projectKey of sourceKeys) {
769
701
  const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
770
- const pendingMode = pendingModeFor(ctx.home, spoolKey);
771
- const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
772
- promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode);
702
+ // U4: a NO-OP firing (already delivered, or a duplicate) promotes nothing: its pending lines are judged by the firing that carries them.
703
+ if (!replayNoop) {
704
+ // A replay judges the session's liveness as the HOOK saw it before it stamped (`firing.prior`), not by the state file the worker
705
+ // may have since written: stamping first would flip `stale-own` to `first-turn` and keep a lingering-id line (P2 point 4).
706
+ const pendingMode = asOf === undefined ? pendingModeFor(ctx.home, spoolKey) : modeFromPrior(ctx.home, spoolKey, asOf);
707
+ // The skew guard stays on `Date.now()`: on `firing.at`, a prompt written after the firing would read as `future` and keep any line.
708
+ const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
709
+ promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode, asOf?.at);
710
+ }
773
711
  if (event === "Stop" || isSessionEnd) {
774
712
  pruneOrphanPending(ctx.home, projectKey, Date.now());
775
713
  const prunedInferred = pruneExpiredInferred(ctx.home, projectKey, Date.now());
@@ -783,11 +721,12 @@ async function deliverTarget(opts) {
783
721
  if (!opts.skipSend) {
784
722
  // ⛔ THE UNION, acknowledged per source (O7): commits, rewrites and inferred squashes of every work tree of this repo for this
785
723
  // session. A single-worktree target reads exactly the three calls the hook always made.
786
- const union = readSpoolUnion(ctx.home, sessionId, ledgerKey, target.worktrees);
724
+ // U4: a replay carries only the commits that existed when its hook fired (the pin). A no-op firing carries none.
725
+ const union = readSpoolUnion(ctx.home, sessionId, ledgerKey, target.worktrees, asOf?.at);
787
726
  // ⛔ THE ONE NEW SPAWN `CR-170` ADDED, for REF MOVES, not commits: a direct look at `HEAD` is the only record of a `pull`,
788
727
  // `checkout` or `reset --hard`. Null on an empty repository. A FOLDER has no HEAD: no spawn.
789
728
  const head = target.kind === "repo" ? headRef(ledgerKey) : null;
790
- delivery = await deliver({
729
+ delivery = replayNoop ? { kind: "nothing-to-send" } : await deliver({
791
730
  home: ctx.home,
792
731
  env: ctx.env,
793
732
  url,
@@ -799,11 +738,16 @@ async function deliverTarget(opts) {
799
738
  // ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that contains it (`CR-204`, D177 §7).
800
739
  transcriptPath: input.transcriptPath,
801
740
  // ⛔ `sendBudget`, not `budgetMs`: the main send is the single largest consumer on this path (`CR-182`).
802
- timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
803
- // PR 4a: WHEN THE SENDS MUST STOP. Shared by every target of the firing, so the rest waits for the next event.
804
- deadlineAtMs: settleStart + sendBudget,
741
+ timeoutMs: requestTimeoutMs,
742
+ // PR 4a: WHEN THE SENDS MUST STOP. Shared by every target of the firing, so the rest waits for the next event. U4: the worker has none.
743
+ ...(deadlineAtMs === undefined ? {} : { deadlineAtMs }),
805
744
  nowMs: Date.now(),
806
- byHook: true,
745
+ ...(heartbeat === undefined ? {} : { beforeRequest: heartbeat }),
746
+ // U5: the worker sends one delta and comes back (fairness and pacing); a hook loops to its budget.
747
+ ...(hookClock === null ? { oneDelta: true } : {}),
748
+ // U4: a replay is not a hook. It never stamps "now"; it stamps the moment its hook fired (`liveAtMs`, merged by `max`).
749
+ byHook: asOf === undefined && hookClock !== null,
750
+ ...(asOf === undefined ? {} : { liveAtMs: asOf.at }),
807
751
  // ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries leave all three unset: each is its own `capture_id`, and
808
752
  // `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so attaching these to all of them would admit several
809
753
  // legal, permanent rows for one commit (D154).
@@ -825,7 +769,7 @@ async function deliverTarget(opts) {
825
769
  // a per-target stop so the next firing does not ask again. Never credential-wide, never `fatal`.
826
770
  if (delivery.kind === "attempted" && delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
827
771
  stopTarget(ctx.home, sessionId, target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
828
- return { target, delivery, subagents, stopped: true };
772
+ return { target, delivery, subagents, stopped: true, reason: targetReason(delivery, 0, eof, true) };
829
773
  }
830
774
  // --- DELEGATED WORK. `CR-124`, D76. ---
831
775
  // Claude Code writes sub-agent transcripts to `<session>/subagents/`, which `transcript_path` never names: ~16% of
@@ -838,10 +782,16 @@ async function deliverTarget(opts) {
838
782
  projectKey: ledgerKey,
839
783
  redactionRoots: target.redactionRoots,
840
784
  repoSlug: target.slug,
841
- startedAt: settleStart,
842
- sendBudget,
843
- byHook: true,
785
+ startedAt: hookClock?.settleStart ?? Date.now(),
786
+ sendBudget: hookClock?.sendBudget ?? 0,
787
+ byHook: asOf === undefined && hookClock !== null,
788
+ ...(asOf === undefined ? {} : { liveAtMs: asOf.at }),
789
+ ...(heartbeat === undefined ? {} : { heartbeat }),
790
+ ...(hookClock === null ? { unboundedTimeoutMs: requestTimeoutMs } : {}),
791
+ // F2d: one request at a time per machine. The main delta just went; the first sub-agent request waits the floor.
792
+ ...(hookClock === null && delivery.kind === "attempted" ? { pauseFirstMs: UNBOUNDED_PACE_FLOOR_MS } : {}),
844
793
  ...(folderCap === undefined ? {} : { sizeCap: folderCap }),
794
+ ...(asOf?.subCap === undefined ? {} : { firingCap: asOf.subCap }),
845
795
  isolation,
846
796
  });
847
797
  }
@@ -853,28 +803,51 @@ async function deliverTarget(opts) {
853
803
  // nothing to return. ⛔ And `CR-228` (`TODOS[142]`): it writes off only what nothing explains. `held` is whether ANY delivery
854
804
  // of this target ended `later`/`fatal`, or was refused by a standing stop. R19 · D22 adds the other case that is HELD, not
855
805
  // written off: `owedAtSessionEnd`, a session other than the one-target in-repo case, whose later firings can finish it.
806
+ //
807
+ // ⛔ U5, D6 — THE WORKER HAS NO BUDGET TO RUN OUT, so it never writes off what is merely BEHIND:
808
+ // - a step that sent one delta and has more owed (`more`) stamps NOTHING: the next step continues, and stamping here would `markSkipped` the rest
809
+ // of a transcript over the delta cap while the server was answering (found by cell R18: the first step reported `done` with 2 MiB written off);
810
+ // - the final step records what is still behind as a HOLD (`endHold`), never as a gap: behind with an answering server just keeps sending, and
811
+ // what the server refused (`never`), an oversize record, a deleted transcript and the backlog caps stamp their gaps where they happen.
856
812
  if (isSessionEnd) {
857
- stampGaps(ctx, input, ledgerKey, [{ fileKey: "main", size: eof }, ...subagents.files], endedHeld(delivery) || subagents.held || opts.owedAtSessionEnd, Date.now());
813
+ const moreOwed = hookClock === null && [delivery, ...subagents.deliveries].some((d) => d.kind === "attempted" && d.more === true);
814
+ if (!moreOwed) {
815
+ stampGaps(ctx, input, ledgerKey, [{ fileKey: "main", size: eof }, ...subagents.files], endedHeld(delivery) || subagents.held || opts.owedAtSessionEnd || hookClock === null, Date.now(), asOf?.at);
816
+ }
858
817
  }
859
- return { target, delivery, subagents, stopped };
818
+ return {
819
+ target,
820
+ delivery,
821
+ subagents,
822
+ stopped,
823
+ reason: targetReason(delivery, fileState(loadSessionState(ctx.home, { repoKey: ledgerKey, sessionId }), "main").sentOffset, eof, stopped),
824
+ };
860
825
  }
861
826
  /** The resolver's scanned offset per stream, for the folder cap. */
862
827
  function scannedOffsets(scan) {
863
828
  return Object.fromEntries(Object.entries(scan).map(([fileKey, cursor]) => [fileKey, cursor.offset]));
864
829
  }
830
+ /**
831
+ * U6b (F2d): THE WORKER'S PACE BETWEEN REQUESTS (D9): sleep `max(250 ms, the request's own elapsed)`, capped at 30 s. It lives here, beside the delivery loops
832
+ * that must honour it, because ONE worker step can put several requests on the wire (the main delta, then one per sub-agent file) and pacing only
833
+ * after the whole step let a fast server take thirteen of them back to back.
834
+ */
835
+ export const UNBOUNDED_PACE_FLOOR_MS = 250;
836
+ export const UNBOUNDED_PACE_CAP_MS = 30_000;
837
+ export function unboundedPaceMs(elapsedMs) {
838
+ return Math.min(UNBOUNDED_PACE_CAP_MS, Math.max(UNBOUNDED_PACE_FLOOR_MS, elapsedMs));
839
+ }
865
840
  /** A session with no firing of its own for this long is IDLE: another session's firing may finish its owed bytes (R19 · D22). */
866
- const DRAIN_IDLE_MS = 10 * 60_000;
841
+ export const DRAIN_IDLE_MS = 10 * 60_000;
867
842
  /** Session records one firing looks at. Examined ones move to the back of the line (their mtime moves), so none starves. */
868
- const DRAIN_EXAMINE_MAX = 40;
843
+ export const DRAIN_EXAMINE_MAX = 40;
869
844
  /** A record whose transcript is gone and that owes nothing is forgotten after this long. */
870
- const DRAIN_FORGET_MS = 30 * 24 * 3600_000;
871
- /** Do not START another session with less than this left of the shared deadline: its first request could not finish. */
872
- const DRAIN_MIN_LEFT_MS = 250;
845
+ export const DRAIN_FORGET_MS = 30 * 24 * 3600_000;
873
846
  /**
874
847
  * An owed target whose transcript is GONE (Claude Code deletes transcripts ~30 days on) can never be sent: its hold becomes the
875
848
  * honest gap now (R19 · D22), and the hold is cleared. Whatever was already delivered is untouched.
876
849
  */
877
- function writeOffOwed(home, record, nowMs) {
850
+ export function writeOffOwed(home, record, nowMs) {
878
851
  for (const target of record.targets) {
879
852
  const key = { repoKey: target.ledgerKey, sessionId: record.sessionId };
880
853
  let session = loadSessionState(home, key);
@@ -889,172 +862,6 @@ function writeOffOwed(home, record, nowMs) {
889
862
  void nowMs;
890
863
  }
891
864
  }
892
- /**
893
- * ⛔ THE IDLE-SESSION DRAIN (R19 · D22 · A). What finishes a session's unsent bytes after its LAST hook.
894
- *
895
- * Each hook run can send a few megabytes, and a session whose last hook ran with bytes still behind (a multi-repo session at
896
- * `SessionEnd`, a repo found late in a long one) has no next hook of its own. So any later `Stop`, `PreCompact` or `SessionEnd`
897
- * on this machine, AFTER its own session's targets and inside the SAME one deadline, finishes sessions that have had no firing
898
- * for 10 minutes: oldest first, each under THAT session's writer lock (never waited for: a busy session is skipped), consent
899
- * re-checked by the resolver, and its discovery continued (≤8 MiB and ≤8 roots per firing, R16), which can add repo targets.
900
- *
901
- * - A session is RETIRED from the drain once its discovery is settled and nothing is behind: it costs a stat and moves to the
902
- * back of the line. Discovery stopping never stops a repo target delivering owed bytes.
903
- * - A folder decision is exactly R18's (`unknown` stays unknown here; backfill offers it); only an ALREADY-sticky folder target
904
- * delivers, and only up to what was scanned.
905
- * - An owed target whose transcript is gone becomes a gap then (`writeOffOwed`).
906
- *
907
- * ⛔ It spends only the LEFTOVER of the one deadline (`settleStart + sendBudget`): the deliveries below honour the same
908
- * `deadlineAtMs`, and no new session starts with under `DRAIN_MIN_LEFT_MS` left. Never throws: a hook cannot say anything.
909
- */
910
- async function drainIdleSessions(opts) {
911
- const { ctx, url, credential, settleStart, sendBudget } = opts;
912
- const deadline = settleStart + sendBudget;
913
- const candidates = listTargetRecords(ctx.home)
914
- .filter(({ record }) => record.sessionId !== opts.currentSessionId)
915
- .sort((a, b) => a.mtimeMs - b.mtimeMs)
916
- .slice(0, DRAIN_EXAMINE_MAX);
917
- for (const { path, record } of candidates) {
918
- if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
919
- return;
920
- const now = Date.now();
921
- if (now - record.firedAt < DRAIN_IDLE_MS)
922
- continue;
923
- // ⛔ A record the drain cannot act on (no transcript path, a transcript gone, a path confinement refuses) must still MOVE to the
924
- // back of the line (C18 #7): the candidates are the oldest records by mtime, and forty of these at the front would otherwise be
925
- // re-examined first on every firing and starve a live owed session behind them.
926
- const rotate = () => {
927
- try {
928
- utimesSync(path, new Date(), new Date());
929
- }
930
- catch {
931
- // Ordering only.
932
- }
933
- };
934
- if (record.transcriptPath === null) {
935
- rotate();
936
- continue;
937
- }
938
- const transcript = record.transcriptPath;
939
- // ⛔ C28: A SESSION OF A DIALECT THAT CANNOT ISOLATE IS NEVER OWED (Cursor; Codex until its own isolation is built). It delivers only
940
- // the repo each turn ends in, on that turn's own firing; there is no later firing to finish for it and no discovery to continue, so
941
- // the drain neither delivers nor writes off for it. It still moves to the back of the line, and the record is forgotten on the
942
- // usual schedule (a session that has not fired for that long is over).
943
- if (!dialectIsolates(ctx.home, ctx.env, transcript)) {
944
- if (now - record.firedAt > DRAIN_FORGET_MS) {
945
- try {
946
- rmSync(path);
947
- }
948
- catch {
949
- // A record we could not delete is just examined again.
950
- }
951
- }
952
- else {
953
- rotate();
954
- }
955
- continue;
956
- }
957
- // ⛔ CONFINEMENT, AGAIN (`/cso` finding 1): this path was read from a state file, not handed over by a gated hook input, and it
958
- // reaches a read whose result is UPLOADED. A path admitted weeks ago can since have become a symlink out of the agent roots,
959
- // and the record is only as trustworthy as the state directory. A file that is not there is not read, so it needs no gate
960
- // (the write-off below touches no bytes).
961
- if (existsSync(transcript) && !admitsTranscript(ctx.home, ctx.env, transcript)) {
962
- rotate();
963
- continue;
964
- }
965
- const size = fileSize(transcript);
966
- if (size === null) {
967
- // ⛔ THE WRITE-OFF IS A WRITE TO THE SESSION'S LEDGERS, so it takes the session's one writer lock like every other writer
968
- // (C18 #6), never waiting: a session a delivery holds right now is skipped and examined on a later firing.
969
- const writeOff = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
970
- if (writeOff === null) {
971
- rotate();
972
- continue;
973
- }
974
- try {
975
- writeOffOwed(ctx.home, record, now);
976
- }
977
- finally {
978
- writeOff.release();
979
- }
980
- if (now - record.firedAt > DRAIN_FORGET_MS) {
981
- try {
982
- rmSync(path);
983
- }
984
- catch {
985
- // A record we could not delete is just examined again.
986
- }
987
- }
988
- else {
989
- rotate();
990
- }
991
- continue;
992
- }
993
- const live = record.targets.filter((target) => record.stopped[stopKeyOf(target)] === undefined);
994
- const subFiles = findSubagentFiles(ctx, transcript);
995
- const behind = live.some((target) => {
996
- const state = loadSessionState(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
997
- return (state.endHold !== null ||
998
- fileState(state, "main").sentOffset < size ||
999
- subFiles.some((file) => fileState(state, file.fileKey).sentOffset < file.size));
1000
- });
1001
- if (!behind && record.settled) {
1002
- // Retired: nothing to send and nothing left to discover. Move it to the back of the line.
1003
- try {
1004
- utimesSync(path, new Date(), new Date());
1005
- }
1006
- catch {
1007
- // Ordering only.
1008
- }
1009
- continue;
1010
- }
1011
- const claim = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
1012
- // ⛔ C29 (iii): a writer is on it right now: not ours to wait for, but it MOVES to the back of the line like every other record the
1013
- // drain cannot act on (C18 #7). Left in place, forty busy records at the front would be re-examined first on every firing.
1014
- if (claim === null) {
1015
- rotate();
1016
- continue;
1017
- }
1018
- try {
1019
- const resolution = resolveTargets({
1020
- home: ctx.home,
1021
- env: ctx.env,
1022
- sessionId: record.sessionId,
1023
- transcriptPath: transcript,
1024
- turnEndCwd: null,
1025
- mode: "turn",
1026
- nowMs: Date.now(),
1027
- drain: true,
1028
- });
1029
- const input = { sessionId: record.sessionId, transcriptPath: transcript, cwd: "", eventName: "Stop" };
1030
- for (const target of leastRecentlySentFirst(ctx.home, record.sessionId, resolution.targets)) {
1031
- if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
1032
- break;
1033
- await deliverTarget({
1034
- ctx,
1035
- input,
1036
- url,
1037
- credential,
1038
- target,
1039
- eof: size,
1040
- event: null,
1041
- isSessionEnd: false,
1042
- settleStart,
1043
- sendBudget,
1044
- consumedMs: Date.now() - settleStart,
1045
- skipSend: false,
1046
- owedAtSessionEnd: false,
1047
- scanned: scannedOffsets(resolution.record.scan),
1048
- record: resolution.record,
1049
- });
1050
- pruneDeliveredEndHold(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
1051
- }
1052
- }
1053
- finally {
1054
- claim.release();
1055
- }
1056
- }
1057
- }
1058
865
  /**
1059
866
  * Send whatever delegated transcripts fit in what is left of the budget.
1060
867
  *
@@ -1104,7 +911,13 @@ export async function deliverSubagents(opts) {
1104
911
  ...announcedSubagentFiles(ctx, input),
1105
912
  ];
1106
913
  const cap = opts.sizeCap;
1107
- const files = cap === undefined ? discovered : discovered.map((f) => ({ ...f, size: Math.min(f.size, cap[f.fileKey] ?? 0) }));
914
+ const fireCap = opts.firingCap;
915
+ const files = cap === undefined && fireCap === undefined
916
+ ? discovered
917
+ : discovered.map((f) => ({
918
+ ...f,
919
+ size: Math.min(f.size, cap === undefined ? f.size : (cap[f.fileKey] ?? 0), fireCap === undefined ? f.size : (fireCap[f.fileKey] ?? 0)),
920
+ }));
1108
921
  if (files.length === 0)
1109
922
  return { files, held: false, deliveries: [] };
1110
923
  // ⚠ SKIP WHAT HAS NOTHING PENDING, BEFORE THE CAP APPLIES — and this is not an
@@ -1125,9 +938,13 @@ export async function deliverSubagents(opts) {
1125
938
  return { files, held: false, deliveries: [] };
1126
939
  let held = false;
1127
940
  const deliveries = [];
1128
- for (const file of pending.slice(0, MAX_SUBAGENT_FILES)) {
941
+ let pauseNextMs = opts.unboundedTimeoutMs === undefined ? null : (opts.pauseFirstMs ?? null);
942
+ for (const file of opts.unboundedTimeoutMs === undefined ? pending.slice(0, MAX_SUBAGENT_FILES) : pending) {
943
+ if (pauseNextMs !== null)
944
+ await wait(pauseNextMs);
945
+ const requestStartedAt = Date.now();
1129
946
  const consumedMs = Date.now() - startedAt;
1130
- if (sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
947
+ if (opts.unboundedTimeoutMs === undefined && sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
1131
948
  break;
1132
949
  if (opts.heartbeat !== undefined && !opts.heartbeat())
1133
950
  break;
@@ -1145,12 +962,14 @@ export async function deliverSubagents(opts) {
1145
962
  // that contains IT is what names its producer.
1146
963
  transcriptPath: file.path,
1147
964
  // The remainder, MEASURED. This is what keeps N files inside one budget.
1148
- timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
965
+ timeoutMs: opts.unboundedTimeoutMs ?? sendTimeoutMs(sendBudget, consumedMs),
1149
966
  // PR 4a: the same origin `consumedMs` is measured from (`startedAt` is the main delivery's `settleStart`).
1150
- deadlineAtMs: startedAt + sendBudget,
967
+ ...(opts.unboundedTimeoutMs === undefined ? { deadlineAtMs: startedAt + sendBudget } : {}),
1151
968
  nowMs: Date.now(),
1152
969
  byHook,
970
+ ...(opts.liveAtMs === undefined ? {} : { liveAtMs: opts.liveAtMs }),
1153
971
  ...(opts.heartbeat === undefined ? {} : { beforeRequest: opts.heartbeat }),
972
+ ...(opts.unboundedTimeoutMs === undefined ? {} : { oneDelta: true }),
1154
973
  }, file.size,
1155
974
  // The SAME reader as the main transcript, so `CR-024d`'s redaction and
1156
975
  // `/cso` finding 1's confinement both apply. A delegated agent reading a
@@ -1159,6 +978,7 @@ export async function deliverSubagents(opts) {
1159
978
  deliveries.push(delivery);
1160
979
  if (endedHeld(delivery))
1161
980
  held = true;
981
+ pauseNextMs = opts.unboundedTimeoutMs !== undefined && delivery.kind === "attempted" ? unboundedPaceMs(Date.now() - requestStartedAt) : null;
1162
982
  }
1163
983
  return { files, held, deliveries };
1164
984
  }
@@ -1236,8 +1056,17 @@ function endedHeld(delivery) {
1236
1056
  */
1237
1057
  function stampGaps(ctx, input, projectKey, files,
1238
1058
  /** A delivery in THIS invocation ended held — see `endedHeld`. */
1239
- heldThisInvocation, nowMs) {
1059
+ heldThisInvocation, nowMs,
1060
+ /**
1061
+ * ⛔ U6b (F1): the time a REPLAY is for (`firing.at`). A worker replays a SessionEnd hours late after an outage or a backlog, and `Date.now()` there
1062
+ * stamped an ended session LIVE now (`liveAt`) and ended now (`endedAt`), so a human's commit (no env session id) became `sole_live_session` for
1063
+ * a session that ended hours ago, and every `later` retry re-stamped it. A replay stamps the hook's time: `endedAt = at`, `liveAt = max(stored, at)`.
1064
+ * Absent for an inline caller, which IS now.
1065
+ */
1066
+ replayAt) {
1240
1067
  const key = { repoKey: projectKey, sessionId: input.sessionId };
1068
+ const at = replayAt ?? nowMs;
1069
+ const liveAtOf = (stored) => (replayAt === undefined ? nowMs : Math.max(stored ?? 0, replayAt));
1241
1070
  const owed = {};
1242
1071
  // Re-read per file rather than once: each `deliver()` above saved, so a
1243
1072
  // snapshot taken before the loop would stamp gaps over work that succeeded.
@@ -1256,19 +1085,19 @@ heldThisInvocation, nowMs) {
1256
1085
  continue;
1257
1086
  }
1258
1087
  // D95's case, unchanged: the server was answering and the BUDGET ran out.
1259
- saveSessionState(ctx.home, key, { ...withFileState(session, file.fileKey, markSkipped(current, current.sentOffset, file.size)), liveAt: nowMs });
1088
+ saveSessionState(ctx.home, key, { ...withFileState(session, file.fileKey, markSkipped(current, current.sentOffset, file.size)), liveAt: liveAtOf(session.liveAt) });
1260
1089
  }
1261
1090
  // The positive record. Written when there is a hold to state OR an old one to
1262
1091
  // clear — a `SessionEnd` that owes nothing must not leave a previous one's
1263
1092
  // claim standing, and one that never held anything need not touch the file.
1264
1093
  const session = loadSessionState(ctx.home, key);
1265
- const endHold = Object.keys(owed).length > 0 ? { at: nowMs, eof: owed } : null;
1094
+ const endHold = Object.keys(owed).length > 0 ? { at, eof: owed } : null;
1266
1095
  // ⭐ `TODOS[146]`: also record THAT the session ended, so `status` can tell a turn that is open because the
1267
1096
  // session is live from one that is open because it is over. Only for a session that has done something
1268
1097
  // (`seq > 0`): a SessionEnd for a session that never delivered must not mint a state file, which would
1269
1098
  // make it "live" to the attribution ladder.
1270
1099
  if (endHold !== null || session.endHold !== null || session.seq > 0) {
1271
- saveSessionState(ctx.home, key, { ...session, endHold, endedAt: nowMs, liveAt: nowMs });
1100
+ saveSessionState(ctx.home, key, { ...session, endHold, endedAt: at, liveAt: liveAtOf(session.liveAt) });
1272
1101
  }
1273
1102
  }
1274
1103
  /**
@@ -1290,7 +1119,7 @@ heldThisInvocation, nowMs) {
1290
1119
  * A missing or unreadable directory is simply "no sub-agents" — silent, per the
1291
1120
  * hook contract.
1292
1121
  */
1293
- function findSubagentFiles(ctx, transcriptPath) {
1122
+ export function findSubagentFiles(ctx, transcriptPath) {
1294
1123
  // ⛔ THE ONE COPY lives in `session_targets.ts` (`findSubagentTranscripts`), shared with the evidence reader so discovery and
1295
1124
  // delivery can never disagree about which files are a session's. Every rule above this line (the dialect's mechanism, the root
1296
1125
  // that selects it, `agent-*.jsonl` and never `agent-*`, confinement of every entry) is enforced there.