@vibe-commit/capture 0.1.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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/agents/claude_code.d.ts +11 -0
- package/dist/agents/claude_code.js +162 -0
- package/dist/agents/claude_code.js.map +1 -0
- package/dist/agents/codex_cli.d.ts +15 -0
- package/dist/agents/codex_cli.js +67 -0
- package/dist/agents/codex_cli.js.map +1 -0
- package/dist/agents/cursor.d.ts +21 -0
- package/dist/agents/cursor.js +89 -0
- package/dist/agents/cursor.js.map +1 -0
- package/dist/agents/registry.d.ts +184 -0
- package/dist/agents/registry.js +212 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/agents/types.d.ts +279 -0
- package/dist/agents/types.js +67 -0
- package/dist/agents/types.js.map +1 -0
- package/dist/commands/connect.d.ts +36 -0
- package/dist/commands/connect.js +499 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/context.d.ts +45 -0
- package/dist/commands/context.js +14 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/report.d.ts +105 -0
- package/dist/commands/report.js +430 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/status.d.ts +29 -0
- package/dist/commands/status.js +236 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/why.d.ts +349 -0
- package/dist/commands/why.js +937 -0
- package/dist/commands/why.js.map +1 -0
- package/dist/consent.d.ts +59 -0
- package/dist/consent.js +111 -0
- package/dist/consent.js.map +1 -0
- package/dist/copy/claims.d.ts +79 -0
- package/dist/copy/claims.js +343 -0
- package/dist/copy/claims.js.map +1 -0
- package/dist/copy/grades.d.ts +117 -0
- package/dist/copy/grades.js +96 -0
- package/dist/copy/grades.js.map +1 -0
- package/dist/copy/index.d.ts +12 -0
- package/dist/copy/index.js +13 -0
- package/dist/copy/index.js.map +1 -0
- package/dist/copy/strings.d.ts +1328 -0
- package/dist/copy/strings.js +1243 -0
- package/dist/copy/strings.js.map +1 -0
- package/dist/copy/time.d.ts +27 -0
- package/dist/copy/time.js +43 -0
- package/dist/copy/time.js.map +1 -0
- package/dist/credential.d.ts +211 -0
- package/dist/credential.js +298 -0
- package/dist/credential.js.map +1 -0
- package/dist/exit.d.ts +59 -0
- package/dist/exit.js +60 -0
- package/dist/exit.js.map +1 -0
- package/dist/git.d.ts +299 -0
- package/dist/git.js +653 -0
- package/dist/git.js.map +1 -0
- package/dist/hooks/entry.d.ts +277 -0
- package/dist/hooks/entry.js +1092 -0
- package/dist/hooks/entry.js.map +1 -0
- package/dist/hooks/post_commit.d.ts +59 -0
- package/dist/hooks/post_commit.js +160 -0
- package/dist/hooks/post_commit.js.map +1 -0
- package/dist/hooks/post_rewrite.d.ts +59 -0
- package/dist/hooks/post_rewrite.js +161 -0
- package/dist/hooks/post_rewrite.js.map +1 -0
- package/dist/hooks/session_end.d.ts +101 -0
- package/dist/hooks/session_end.js +120 -0
- package/dist/hooks/session_end.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +360 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +153 -0
- package/dist/install.js +284 -0
- package/dist/install.js.map +1 -0
- package/dist/json.d.ts +100 -0
- package/dist/json.js +94 -0
- package/dist/json.js.map +1 -0
- package/dist/oauth/discovery.d.ts +96 -0
- package/dist/oauth/discovery.js +163 -0
- package/dist/oauth/discovery.js.map +1 -0
- package/dist/oauth/lock.d.ts +100 -0
- package/dist/oauth/lock.js +287 -0
- package/dist/oauth/lock.js.map +1 -0
- package/dist/oauth/loopback.d.ts +85 -0
- package/dist/oauth/loopback.js +124 -0
- package/dist/oauth/loopback.js.map +1 -0
- package/dist/oauth/pkce.d.ts +58 -0
- package/dist/oauth/pkce.js +77 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/read.d.ts +120 -0
- package/dist/oauth/read.js +144 -0
- package/dist/oauth/read.js.map +1 -0
- package/dist/oauth/session.d.ts +80 -0
- package/dist/oauth/session.js +112 -0
- package/dist/oauth/session.js.map +1 -0
- package/dist/oauth/signin.d.ts +178 -0
- package/dist/oauth/signin.js +225 -0
- package/dist/oauth/signin.js.map +1 -0
- package/dist/oauth/token.d.ts +139 -0
- package/dist/oauth/token.js +183 -0
- package/dist/oauth/token.js.map +1 -0
- package/dist/paths.d.ts +366 -0
- package/dist/paths.js +460 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy.d.ts +195 -0
- package/dist/policy.js +244 -0
- package/dist/policy.js.map +1 -0
- package/dist/post.d.ts +302 -0
- package/dist/post.js +384 -0
- package/dist/post.js.map +1 -0
- package/dist/project.d.ts +99 -0
- package/dist/project.js +192 -0
- package/dist/project.js.map +1 -0
- package/dist/redact.d.ts +175 -0
- package/dist/redact.js +700 -0
- package/dist/redact.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.js +37 -0
- package/dist/runtime.js.map +1 -0
- package/dist/spawn_budget.d.ts +101 -0
- package/dist/spawn_budget.js +124 -0
- package/dist/spawn_budget.js.map +1 -0
- package/dist/spool.d.ts +390 -0
- package/dist/spool.js +556 -0
- package/dist/spool.js.map +1 -0
- package/dist/state.d.ts +109 -0
- package/dist/state.js +217 -0
- package/dist/state.js.map +1 -0
- package/dist/system_message.d.ts +101 -0
- package/dist/system_message.js +141 -0
- package/dist/system_message.js.map +1 -0
- package/dist/term.d.ts +112 -0
- package/dist/term.js +134 -0
- package/dist/term.js.map +1 -0
- package/dist/version.d.ts +16 -0
- package/dist/version.js +17 -0
- package/dist/version.js.map +1 -0
- package/package.json +50 -0
package/dist/paths.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `~/.vibecommit/` layout.
|
|
3
|
+
*
|
|
4
|
+
* One module so the fifteen tasks that build on this one do not each invent a
|
|
5
|
+
* path. Every function takes `home` explicitly rather than calling `os.homedir()`
|
|
6
|
+
* internally — the contract test runs the real binary against a temp HOME, and a
|
|
7
|
+
* module that reads the real one cannot be tested without touching the
|
|
8
|
+
* developer's own credentials.
|
|
9
|
+
*
|
|
10
|
+
* `CR-017d` added the repo to the binding key and hangs per-repo send state off
|
|
11
|
+
* `stateDir` — see `sessionStatePath` and `SessionKey` below.
|
|
12
|
+
*
|
|
13
|
+
* @provenance vibecommit-schema 20260809150000_cr017a_repo_binding_key.sql — PK shape, mirrored
|
|
14
|
+
* @provenance vibecommit-schema ingest_sessions.file_key — CHECK bound 1..128, retyped
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { realpathSync } from "node:fs";
|
|
18
|
+
import { join, sep } from "node:path";
|
|
19
|
+
/** Root of the client's own state. Never Claude Code's — see the plan's keychain note. */
|
|
20
|
+
export function rootDir(home) {
|
|
21
|
+
return join(home, ".vibecommit");
|
|
22
|
+
}
|
|
23
|
+
/** The ingest credential envelope. Mode 0600, enforced on read (`credential.ts`). */
|
|
24
|
+
export function credentialsPath(home) {
|
|
25
|
+
return join(rootDir(home), "credentials.json");
|
|
26
|
+
}
|
|
27
|
+
/** The per-project consent allow list. Consulted BEFORE any transcript read (D56 §D19). */
|
|
28
|
+
export function projectsPath(home) {
|
|
29
|
+
return join(rootDir(home), "projects.json");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The HUMAN lane's signed-in session — `CR-084d`. Mode 0600, enforced on read by
|
|
33
|
+
* the same `readSecretFile` the ingest credential goes through.
|
|
34
|
+
*
|
|
35
|
+
* A SECOND FILE and not a second field in `credentials.json`, deliberately. The
|
|
36
|
+
* two hold different privilege classes on different lanes: `credentials.json`
|
|
37
|
+
* holds the machine's opaque ingest credential and is read by every hook, and
|
|
38
|
+
* this holds a user-principal refresh token read only by interactive verbs. One
|
|
39
|
+
* file would mean the hook path opens the more powerful secret on every turn for
|
|
40
|
+
* no reason, and `VIBECOMMIT_TOKEN` (which overrides the ingest credential and
|
|
41
|
+
* nothing else) would acquire a meaning it does not have.
|
|
42
|
+
*/
|
|
43
|
+
export function sessionPath(home) {
|
|
44
|
+
return join(rootDir(home), "session.json");
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Client state. `CR-023`'s capture-confirmed key lives here under `(repo, day)`;
|
|
48
|
+
* the three negative `systemMessage` states use a per-session file.
|
|
49
|
+
*/
|
|
50
|
+
export function stateDir(home) {
|
|
51
|
+
return join(rootDir(home), "state");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Once-per-session marker for a `systemMessage` state.
|
|
55
|
+
*
|
|
56
|
+
* Keyed on `(session, state)` and NOT on the transcript file: `ER23` keys client
|
|
57
|
+
* state per file, which would fire the same message once for `main` and once for
|
|
58
|
+
* every `subagents/agent-*.jsonl` in the same session.
|
|
59
|
+
*/
|
|
60
|
+
export function sessionNoticePath(home, sessionId, state) {
|
|
61
|
+
return join(stateDir(home), "notices", sanitise(sessionId), `${sanitise(state)}.json`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Once-per-`(repo, day)` marker for the capture-confirmed notice — `CR-023d`.
|
|
65
|
+
*
|
|
66
|
+
* **The key is neither the session nor "ever", and both alternatives are wrong
|
|
67
|
+
* in a way you can feel.** `PreCompact` mints a fresh `session_id` mid-task, so
|
|
68
|
+
* a per-session key fires the one positive message twice in an afternoon and
|
|
69
|
+
* reads as a bug. A key with no day component fires it exactly once ever, which
|
|
70
|
+
* under-communicates on a tool used daily — and this is the single positive beat
|
|
71
|
+
* in a channel that is otherwise silent by design (style guide §10.1).
|
|
72
|
+
*
|
|
73
|
+
* A per-FILE key would be wrong too, once `CR-124` (W6) starts producing
|
|
74
|
+
* `agent-*` file keys: it would fire once for `main` and once per sub-agent
|
|
75
|
+
* transcript in the same session. The key is deliberately chosen so that landing
|
|
76
|
+
* `CR-124` cannot change how often this message appears.
|
|
77
|
+
*
|
|
78
|
+
* `repo-day/` namespaces this away from `sessionNoticePath`'s
|
|
79
|
+
* `notices/<session>/<state>.json`: the two live at different depths under
|
|
80
|
+
* `notices/`, so no session id can ever resolve onto a repo-day file.
|
|
81
|
+
*
|
|
82
|
+
* The repo is the same SHA-256 digest `sessionStatePath` uses, for the same
|
|
83
|
+
* reason — `sanitise` truncates at 128 characters, so two deep checkouts sharing
|
|
84
|
+
* a prefix would land on one key and the second repo would never see its
|
|
85
|
+
* confirmation. `day` is supplied by the caller as a UTC `YYYY-MM-DD`
|
|
86
|
+
* (`system_message.ts`'s `utcDay`), never read from a local clock here.
|
|
87
|
+
*/
|
|
88
|
+
export function repoDayNoticePath(home, repoKey, day, state) {
|
|
89
|
+
return join(stateDir(home), "notices", "repo-day", repoDigest(repoKey), sanitise(day), `${sanitise(state)}.json`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Per-`(repo, session)` send state — `CR-018`'s offset ledger, backlog and
|
|
93
|
+
* terminal stop, keyed by `CR-017d`.
|
|
94
|
+
*
|
|
95
|
+
* **Returns null when there is no repo identity, and that is the fix.** D58: *"The
|
|
96
|
+
* server key gains repo; the client offset state did not, so a `prefix_sha256`
|
|
97
|
+
* resync would duplicate repo 1's turns into repo 2."* Keyed on the session alone
|
|
98
|
+
* this file was machine-wide, so one Claude Code session whose `cwd` moved between
|
|
99
|
+
* two repos read and wrote the same offsets for both. A caller with no repo must
|
|
100
|
+
* therefore get NO path at all rather than a shared bucket — a placeholder key is
|
|
101
|
+
* the bleed with an extra step (D72's "required from day one", mirrored).
|
|
102
|
+
*
|
|
103
|
+
* The repo is a SHA-256 digest and not the sanitised path: `sanitise` truncates at
|
|
104
|
+
* 128 characters, and two work trees sharing a long prefix would land in one
|
|
105
|
+
* directory — a silent collision at exactly the boundary being defended.
|
|
106
|
+
*
|
|
107
|
+
* Still one file per session rather than one per `(session, file)`, because `seq`
|
|
108
|
+
* is monotonic per SESSION across the main transcript and every sub-agent's, so
|
|
109
|
+
* splitting the store would split the counter that has to stay single.
|
|
110
|
+
*/
|
|
111
|
+
export function sessionStatePath(home, key) {
|
|
112
|
+
const dir = repoSessionsDir(home, key.repoKey);
|
|
113
|
+
if (dir === null)
|
|
114
|
+
return null;
|
|
115
|
+
return join(dir, `${sanitise(key.sessionId)}.json`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Every session file for ONE repo — the directory `sessionStatePath` writes into.
|
|
119
|
+
*
|
|
120
|
+
* `status` needs this and a hook does not: a hook is handed a `session_id` on
|
|
121
|
+
* stdin, but `vibecommit status` is interactive and has none, so the only way it
|
|
122
|
+
* can answer "when did this repo last send?" is to look at every session under
|
|
123
|
+
* the repo (`CR-021`). Exposed rather than duplicated so there is still exactly
|
|
124
|
+
* one place that knows the layout, and the digest stays private.
|
|
125
|
+
*
|
|
126
|
+
* Null on no repo, for the same reason `sessionStatePath` is: there is no
|
|
127
|
+
* repo-less bucket, and a caller with no repo has nothing to scan.
|
|
128
|
+
*/
|
|
129
|
+
export function repoSessionsDir(home, repoKey) {
|
|
130
|
+
if (repoKey === "")
|
|
131
|
+
return null;
|
|
132
|
+
return join(stateDir(home), "sessions", repoDigest(repoKey));
|
|
133
|
+
}
|
|
134
|
+
/** A fixed-length, collision-free filename component for a work-tree path. */
|
|
135
|
+
function repoDigest(repoKey) {
|
|
136
|
+
return createHash("sha256").update(repoKey).digest("hex").slice(0, 32);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Where Claude Code writes transcripts.
|
|
140
|
+
*
|
|
141
|
+
* `CLAUDE_CONFIG_DIR` is honoured because Claude Code honours it; hard-coding
|
|
142
|
+
* `~/.claude` would silently capture nothing for anyone who relocated it.
|
|
143
|
+
*/
|
|
144
|
+
export function transcriptRoot(home, env) {
|
|
145
|
+
const configured = env.CLAUDE_CONFIG_DIR;
|
|
146
|
+
const root = configured !== undefined && configured.trim() !== ""
|
|
147
|
+
? configured.trim()
|
|
148
|
+
: join(home, ".claude");
|
|
149
|
+
return join(root, "projects");
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Where Codex CLI writes transcripts — `CR-183`, D164.
|
|
153
|
+
*
|
|
154
|
+
* BESIDE `transcriptRoot` rather than inside it, because the two roots are two
|
|
155
|
+
* facts about two products: `CODEX_HOME ?? ~/.codex` plus `sessions`, against
|
|
156
|
+
* `CLAUDE_CONFIG_DIR ?? ~/.claude` plus `projects`. Folding them into one
|
|
157
|
+
* function taking a discriminant would make the union below read as a branch on
|
|
158
|
+
* a flag rather than as an enumeration of the registry, which is the property
|
|
159
|
+
* the confinement check depends on.
|
|
160
|
+
*
|
|
161
|
+
* ⚠ **The env var is honoured on the same evidence the Claude one is: because
|
|
162
|
+
* the product honours it.** Hard-coding `~/.codex` would silently capture
|
|
163
|
+
* nothing for anyone who relocated it.
|
|
164
|
+
*/
|
|
165
|
+
export function codexTranscriptRoot(home, env) {
|
|
166
|
+
const configured = env.CODEX_HOME;
|
|
167
|
+
const root = configured !== undefined && configured.trim() !== ""
|
|
168
|
+
? configured.trim()
|
|
169
|
+
: join(home, ".codex");
|
|
170
|
+
return join(root, "sessions");
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Where Cursor writes transcripts — `CR-193`, D177 §2.
|
|
174
|
+
*
|
|
175
|
+
* ⛔ **`projects`, and NOT the `agent-transcripts` directory D177 §2 names —
|
|
176
|
+
* because that root IS NOT EXPRESSIBLE HERE, and the reason is measured rather
|
|
177
|
+
* than argued.**
|
|
178
|
+
*
|
|
179
|
+
* One conversation's transcript lives at
|
|
180
|
+
* `~/.cursor/projects/<munged>/agent-transcripts/<cid>/<cid>.jsonl`, where
|
|
181
|
+
* `<munged>` is the WORKSPACE fsPath with every non-alphanumeric run collapsed
|
|
182
|
+
* to `-`. A `transcriptRoot` member is handed `home` and `env` and nothing else,
|
|
183
|
+
* and `<munged>` is derivable from neither: this machine already carries two of
|
|
184
|
+
* them, `Users-acohan-Documents-VibeCommit` and `empty-window`.
|
|
185
|
+
*
|
|
186
|
+
* ⚠ **And the obvious rescue — derive it from the consented project key — is
|
|
187
|
+
* measured WRONG, not merely awkward.** `resolveProjectKey` is
|
|
188
|
+
* `git rev-parse --show-toplevel`, and the workspace that produced the D177 §10
|
|
189
|
+
* corpus, `/Users/acohan/Documents/VibeCommit`, is **not a git repository at
|
|
190
|
+
* all**. The repository that IS one sits a directory below it, so its project
|
|
191
|
+
* key munges to that workspace's name with the child directory appended — a
|
|
192
|
+
* name with no directory under `projects/` — and a project-key-derived root
|
|
193
|
+
* would refuse the one real transcript there is.
|
|
194
|
+
*
|
|
195
|
+
* ⇒ ⛔ **The interior shape is enforced by `isCursorTranscript` instead**, which
|
|
196
|
+
* runs AFTER the registry-computed boundary and can only refuse. Read that
|
|
197
|
+
* function's docblock for what this changes about D177 §2's stated reason: it
|
|
198
|
+
* inverts it.
|
|
199
|
+
*
|
|
200
|
+
* ⚠ **No environment override, and that is an absence rather than an
|
|
201
|
+
* oversight.** `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honoured because those
|
|
202
|
+
* products honour them. No Cursor equivalent has been measured, and inventing
|
|
203
|
+
* one would be a configuration surface this client reads and nothing writes.
|
|
204
|
+
*/
|
|
205
|
+
export function cursorTranscriptRoot(home) {
|
|
206
|
+
return join(home, ".cursor", "projects");
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The one child of a Cursor workspace directory that holds transcripts.
|
|
210
|
+
*
|
|
211
|
+
* Its three measured siblings are `agent-tools/` (scraped web content),
|
|
212
|
+
* `canvases/` (a React/TypeScript scratch project, `node_modules` and all) and
|
|
213
|
+
* `mcps/` (per-server tool schemas). D177 §2.
|
|
214
|
+
*/
|
|
215
|
+
const CURSOR_TRANSCRIPTS_DIR = "agent-transcripts";
|
|
216
|
+
/**
|
|
217
|
+
* ⛔ **THE `.jsonl` GATE — `CR-193`, D177 §2**, which supersedes `CR-183`'s
|
|
218
|
+
* exclusion of `transcriptExtension`.
|
|
219
|
+
*
|
|
220
|
+
* The lane's file gate: `transport: "ndjson"` says every member appends NDJSON
|
|
221
|
+
* to a file, and `.jsonl` is what that file is called. Confinement answers
|
|
222
|
+
* *"may this path be read at all"*; this answers *"is this a transcript"*, and
|
|
223
|
+
* the two are different questions — a root can be exactly right and still
|
|
224
|
+
* contain files that are not transcripts.
|
|
225
|
+
*
|
|
226
|
+
* ⚠ **`root` is unused HERE and is a parameter of the member, because Cursor's
|
|
227
|
+
* answer needs it.** Under Claude's and Codex's roots every file is a
|
|
228
|
+
* transcript, so the suffix is the whole test; under Cursor's it is not.
|
|
229
|
+
*
|
|
230
|
+
* ⚠ **No `realpath`, deliberately, and only here.** A symlink whose name ends
|
|
231
|
+
* `.jsonl` and whose target does not is a real shape, and `isCursorTranscript`
|
|
232
|
+
* resolves for exactly that reason — but under these two roots there is nothing
|
|
233
|
+
* to point at: every file under them is a transcript already. Paying two
|
|
234
|
+
* syscalls per hook to defend a set with no members is the cost this note exists
|
|
235
|
+
* to decline.
|
|
236
|
+
*/
|
|
237
|
+
export function isTranscriptFile(_root, path) {
|
|
238
|
+
return path.endsWith(TRANSCRIPT_SUFFIX);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The lane's gate PLUS the interior shape Cursor's root cannot declare —
|
|
242
|
+
* `CR-193`, D177 §2.
|
|
243
|
+
*
|
|
244
|
+
* ⛔ **AND THIS IS WHERE D177 §2's STATED REASON INVERTS. Say it in these
|
|
245
|
+
* words, because the polarity is the whole point.** §2 records that the ROOT is
|
|
246
|
+
* what excludes the 32 KB scraped-web-content `.txt`, and that the gate
|
|
247
|
+
* therefore ships as fail-closed defence with **no corpus witness** —
|
|
248
|
+
* `agent-tools/` being a SIBLING of `agent-transcripts/`. That is true of the
|
|
249
|
+
* root §2 names. It is **false of the root that is expressible** (see
|
|
250
|
+
* `cursorTranscriptRoot`): rooted at `projects`, `find -type f` returns **113
|
|
251
|
+
* files, exactly 1 of them the transcript** — 66 `.json`, 34 `.ts`, 8 `.md`,
|
|
252
|
+
* 3 extensionless, 1 `.txt`. ⇒ ⛔ **the gate has 112 corpus witnesses and IS
|
|
253
|
+
* what excludes the `.txt`.** It is not defence in depth here; it is the
|
|
254
|
+
* mechanism.
|
|
255
|
+
*
|
|
256
|
+
* ⚠ **THOSE FIGURES ARE A CORRECTION.** This docblock first said 112/111. The
|
|
257
|
+
* total was summed from a per-extension HISTOGRAM rather than read from
|
|
258
|
+
* `find … | wc -l`, and the histogram's own `.jsonl` row was dropped in the
|
|
259
|
+
* addition — `feedback_bounded_output_is_not_a_count`, on the very measurement
|
|
260
|
+
* that motivates this function. Re-measured three ways that must agree:
|
|
261
|
+
* `-type f` = 113, `-name '*.jsonl'` = 1, `-not -name '*.jsonl'` = 112.
|
|
262
|
+
*
|
|
263
|
+
* ⛔ **The segment test is what puts D177 §2's boundary back.** A `.jsonl`
|
|
264
|
+
* anywhere under `projects` would otherwise be admitted for upload, and
|
|
265
|
+
* `agent-tools/` is precisely where Cursor spills whatever the agent
|
|
266
|
+
* downloaded. Requiring `<root>/<workspace>/agent-transcripts/…` reconstructs
|
|
267
|
+
* the root §2 asked for, as a narrowing applied AFTER the registry-computed
|
|
268
|
+
* boundary — so it can only refuse, never widen (D177 §7's direction test).
|
|
269
|
+
*
|
|
270
|
+
* ⚠ **`M0.3` measured ZERO non-`.jsonl` files INSIDE `agent-transcripts/`** (the
|
|
271
|
+
* whole directory holds one file). So the suffix half of this test still has no
|
|
272
|
+
* witness under the segment half, and absence from a corpus is not absence from
|
|
273
|
+
* the product — `D172 §2`'s `notebook_path` reasoning, unchanged.
|
|
274
|
+
*
|
|
275
|
+
* ⛔ **Resolved, because the segment names are the thing being trusted.**
|
|
276
|
+
* `isInside` already resolves both sides, so a path claiming to be under this
|
|
277
|
+
* root cannot be outside it; what a string test would still miss is a symlink
|
|
278
|
+
* INSIDE the root — `…/agent-transcripts/x.jsonl` pointing at
|
|
279
|
+
* `…/agent-tools/scrape.txt`, which passes confinement on the real path and
|
|
280
|
+
* passes a naive segment test on the claimed one. `isInside` is reused rather
|
|
281
|
+
* than re-implemented (`CR-183`); the resolution below exists only to locate the
|
|
282
|
+
* segments within it.
|
|
283
|
+
*/
|
|
284
|
+
export function isCursorTranscript(root, path) {
|
|
285
|
+
if (!isInside(root, path))
|
|
286
|
+
return false;
|
|
287
|
+
let realRoot;
|
|
288
|
+
let realPath;
|
|
289
|
+
try {
|
|
290
|
+
realRoot = realpathSync(root);
|
|
291
|
+
realPath = realpathSync(path);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (!realPath.endsWith(TRANSCRIPT_SUFFIX))
|
|
297
|
+
return false;
|
|
298
|
+
const segments = realPath.slice(realRoot.length + 1).split(sep);
|
|
299
|
+
// `<workspace>` / `agent-transcripts` / at least one thing under it.
|
|
300
|
+
return segments.length >= 3 && segments[1] === CURSOR_TRANSCRIPTS_DIR;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Where Claude Code writes DELEGATED work — `CR-124`, D76.
|
|
304
|
+
*
|
|
305
|
+
* `<dir>/<session-id>/subagents/`, beside `<dir>/<session-id>.jsonl`. Derived
|
|
306
|
+
* from `transcript_path` because there is no environment variable for it and
|
|
307
|
+
* the hook is never handed one: the session id is the transcript's own basename.
|
|
308
|
+
*
|
|
309
|
+
* Measured on this machine rather than read from documentation — and the
|
|
310
|
+
* measurement carries a trap. The directory holds an `agent-<id>.meta.json`
|
|
311
|
+
* sidecar beside every `agent-<id>.jsonl`, **636 of each**, exactly 1:1. A
|
|
312
|
+
* `agent-*` glob would therefore double every upload and send metadata as if it
|
|
313
|
+
* were transcript content. The caller globs `agent-*.jsonl`.
|
|
314
|
+
*
|
|
315
|
+
* Returns null for a path that is not a `.jsonl`, rather than inventing a
|
|
316
|
+
* directory next to something that was never a transcript.
|
|
317
|
+
*/
|
|
318
|
+
export function subagentsDir(transcriptPath) {
|
|
319
|
+
if (!transcriptPath.endsWith(TRANSCRIPT_SUFFIX))
|
|
320
|
+
return null;
|
|
321
|
+
return join(transcriptPath.slice(0, -TRANSCRIPT_SUFFIX.length), "subagents");
|
|
322
|
+
}
|
|
323
|
+
const TRANSCRIPT_SUFFIX = ".jsonl";
|
|
324
|
+
/**
|
|
325
|
+
* The `file_key` a sub-agent transcript travels under: its basename, minus
|
|
326
|
+
* `.jsonl`.
|
|
327
|
+
*
|
|
328
|
+
* From the FILENAME, deliberately. Every record inside also carries `agentId`,
|
|
329
|
+
* but reading a value out of a file to name that file is analysis this client is
|
|
330
|
+
* not licensed to do (D60 §D6 / §D1a) and does not need.
|
|
331
|
+
*
|
|
332
|
+
* Null when the key would breach the server's 1..128 bound
|
|
333
|
+
* (`ingest_sessions.file_key`'s CHECK, `CR-008`): a delta the server 400s is
|
|
334
|
+
* classed `never` and would advance the offset past bytes it never stored.
|
|
335
|
+
* Longest key measured on this machine is 49 characters, so this is a bound
|
|
336
|
+
* being respected rather than one being hit.
|
|
337
|
+
*/
|
|
338
|
+
export function subagentFileKey(fileName) {
|
|
339
|
+
if (!fileName.startsWith("agent-") || !fileName.endsWith(TRANSCRIPT_SUFFIX))
|
|
340
|
+
return null;
|
|
341
|
+
const key = fileName.slice(0, -TRANSCRIPT_SUFFIX.length);
|
|
342
|
+
return key.length >= 1 && key.length <= 128 ? key : null;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The `file_key` an ANNOUNCED sub-agent transcript travels under — `CR-196`,
|
|
346
|
+
* D177 §9. ⛔ **`agent-<childId>`, SYNTHESIZED HERE, CLIENT-SIDE.**
|
|
347
|
+
*
|
|
348
|
+
* ## ⛔ Why this sits BESIDE `subagentFileKey` and not inside it
|
|
349
|
+
*
|
|
350
|
+
* They are opposite jobs on opposite layouts, and one function doing both would
|
|
351
|
+
* be strictly worse at each:
|
|
352
|
+
*
|
|
353
|
+
* `subagentFileKey` a FILTER. Claude's `subagents/` directory is globbed, so
|
|
354
|
+
* the caller sees `agent-<id>.jsonl`, `agent-<id>.meta.json`
|
|
355
|
+
* and anything else that is in there — and the prefix test
|
|
356
|
+
* is what REFUSES the sidecar and `main.jsonl`. Relaxing it
|
|
357
|
+
* to "mint a prefix if one is missing" would make the
|
|
358
|
+
* discover path accept every one of those.
|
|
359
|
+
* this a CONSTRUCTOR. Cursor's children are `<childId>.jsonl`
|
|
360
|
+
* with **no prefix at all**, and the path was HANDED over
|
|
361
|
+
* rather than found, so there is nothing to filter — the
|
|
362
|
+
* only question is what to call the stream.
|
|
363
|
+
*
|
|
364
|
+
* ## ⛔ The key stays FILENAME-derived, which is what keeps D60 §D6 intact
|
|
365
|
+
*
|
|
366
|
+
* `<childId>` is the announced path's BASENAME. It is not read out of any
|
|
367
|
+
* record inside the file, so *"do not read a value out of a file to name that
|
|
368
|
+
* file"* still holds — the same rule `subagentFileKey`'s own docblock states
|
|
369
|
+
* about `agentId`. What arrives on stdin is a PATH; this reads its name.
|
|
370
|
+
*
|
|
371
|
+
* ## ⛔ WIDENING `ingest_sessions.file_key` WAS CONSIDERED AND DECLINED (D177 §9)
|
|
372
|
+
*
|
|
373
|
+
* Five independent layers refuse a bare `<childId>`, and a DB-only widening
|
|
374
|
+
* would not have worked at all: `subagentFileKey`'s own prefix test, `post.ts`'s
|
|
375
|
+
* `FileKey` type, `cr008`'s CHECK (whose C5/C6/C7 cells pin more than the CHECK
|
|
376
|
+
* line shows), the server's family check — where a 400 classes `never`, advances
|
|
377
|
+
* the offset and stamps a PERMANENT gap — and the health classifier, which
|
|
378
|
+
* would silently reclassify every sub-agent stream as a MAIN stream. Synthesis
|
|
379
|
+
* touches none of them: no migration, no production deploy.
|
|
380
|
+
*
|
|
381
|
+
* ## The prefix is added UNCONDITIONALLY, and that is what makes it injective
|
|
382
|
+
*
|
|
383
|
+
* A child literally named `agent-x.jsonl` becomes `agent-agent-x`, which is
|
|
384
|
+
* ugly and is the only answer that cannot collide: `x` → `agent-x` and
|
|
385
|
+
* `agent-x` → `agent-agent-x` stay distinct, where "prefix only if missing"
|
|
386
|
+
* maps both onto `agent-x` — two streams sharing one offset ledger.
|
|
387
|
+
*
|
|
388
|
+
* Null when the key would breach the server's 1..128 bound, exactly as
|
|
389
|
+
* `subagentFileKey` is, and null for an empty `<childId>`: a bare `agent-` is
|
|
390
|
+
* REJECTED by `cr008`'s CHECK (`like 'agent-_%'`), and its C5 cell pins that.
|
|
391
|
+
*/
|
|
392
|
+
export function announcedSubagentFileKey(fileName) {
|
|
393
|
+
if (!fileName.endsWith(TRANSCRIPT_SUFFIX))
|
|
394
|
+
return null;
|
|
395
|
+
const childId = fileName.slice(0, -TRANSCRIPT_SUFFIX.length);
|
|
396
|
+
if (childId === "")
|
|
397
|
+
return null;
|
|
398
|
+
const key = `agent-${childId}`;
|
|
399
|
+
return key.length <= 128 ? key : null;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Is `candidate` really inside `root`?
|
|
403
|
+
*
|
|
404
|
+
* `/cso` finding 1 (2026-08-09), HIGH. `transcript_path` arrives on hook stdin
|
|
405
|
+
* and was read and uploaded verbatim under the user's ingest credential, with no
|
|
406
|
+
* check that it was a transcript at all. Anything that could put a hook
|
|
407
|
+
* definition in front of Claude Code could therefore point the client at
|
|
408
|
+
* `~/.ssh/id_rsa` and have it shipped off the machine — and the hook contract's
|
|
409
|
+
* exit-0 silence meant nothing would show on any stream. The silence that makes
|
|
410
|
+
* the hook safe for the developer's turn is what made that quiet.
|
|
411
|
+
*
|
|
412
|
+
* `realpathSync` on BOTH sides, so a symlink cannot walk out of the root: a
|
|
413
|
+
* string prefix test on the unresolved path is exactly the check that misses
|
|
414
|
+
* `~/.claude/projects/x -> /etc`. A missing path resolves to "outside", because
|
|
415
|
+
* a transcript we cannot resolve is not one we should read.
|
|
416
|
+
*/
|
|
417
|
+
export function isInside(root, candidate) {
|
|
418
|
+
let realRoot;
|
|
419
|
+
let realCandidate;
|
|
420
|
+
try {
|
|
421
|
+
realRoot = realpathSync(root);
|
|
422
|
+
realCandidate = realpathSync(candidate);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
return realCandidate === realRoot || realCandidate.startsWith(realRoot + sep);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Is `candidate` inside ANY of `roots`? — `CR-183`, D164 §5.
|
|
431
|
+
*
|
|
432
|
+
* ⛔ **A WRAPPER, and `isInside` above is byte-unchanged.** The union is a
|
|
433
|
+
* second agent's root being added to the SET the check runs over; it is not a
|
|
434
|
+
* loosening of what "inside" means, and rewriting the resolved-path comparison
|
|
435
|
+
* to take a list would have put the `/cso` finding-1 remediation back on the
|
|
436
|
+
* table for a change that is about membership.
|
|
437
|
+
*
|
|
438
|
+
* ⛔ **`roots` comes from the REGISTRY. It must never be derived from input.**
|
|
439
|
+
* A caller that built this list out of anything on hook stdin would have handed
|
|
440
|
+
* the confinement boundary to the value the boundary exists to constrain.
|
|
441
|
+
*
|
|
442
|
+
* Empty `roots` refuses everything, which is the right direction: no known root
|
|
443
|
+
* means nothing is known to be inside one.
|
|
444
|
+
*/
|
|
445
|
+
export function isInsideAny(roots, candidate) {
|
|
446
|
+
return roots.some((root) => isInside(root, candidate));
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Reduce an untrusted id to a filename component.
|
|
450
|
+
*
|
|
451
|
+
* `session_id` arrives on hook stdin and is attacker-influenced in the sense that
|
|
452
|
+
* matters here: it reaches `path.join`. Whitelisting is the only safe direction —
|
|
453
|
+
* a blacklist of `..` and `/` misses NUL, backslash on Windows, and the empty
|
|
454
|
+
* string, each of which resolves somewhere other than intended.
|
|
455
|
+
*/
|
|
456
|
+
function sanitise(value) {
|
|
457
|
+
const cleaned = value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128);
|
|
458
|
+
return cleaned === "" || /^\.+$/.test(cleaned) ? "_" : cleaned;
|
|
459
|
+
}
|
|
460
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.js","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"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"}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failure policy — CR-018, D58 (plan §ER22/§ER10/§ER11/§ER4).
|
|
3
|
+
*
|
|
4
|
+
* The defect this replaces, verbatim from the register: *"5xx was unclassified
|
|
5
|
+
* and fell through to advance-and-discard; 401/413 never succeed."* Every failed
|
|
6
|
+
* POST used to reach the same code, so the offset moved forward as if the bytes
|
|
7
|
+
* had been delivered. They had not.
|
|
8
|
+
*
|
|
9
|
+
* ## Three classes, and why `never` and `fatal` are not the same class
|
|
10
|
+
*
|
|
11
|
+
* later Hold the offset. The NEXT hook invocation retries from the same
|
|
12
|
+
* point. Transient: 5xx, no-response, 429, and 403 — a 403 here is
|
|
13
|
+
* org-approval-pending, which means later, not failed (D60 §D12).
|
|
14
|
+
* never Advance PAST the failing bytes and stamp a gap, so the record shows
|
|
15
|
+
* an honest hole instead of pretending nothing happened. Per-PAYLOAD:
|
|
16
|
+
* resending the identical bytes cannot work (413, structurally
|
|
17
|
+
* malformed 4xx).
|
|
18
|
+
* fatal Stop attempting further sends for this session until the user
|
|
19
|
+
* reconnects. Per-CREDENTIAL, which is the whole difference from
|
|
20
|
+
* `never`: 401 does not describe these bytes, it describes the token.
|
|
21
|
+
*
|
|
22
|
+
* **`later` never means a synchronous retry inside one hook invocation.** D56
|
|
23
|
+
* plan §D8 is a founder override that is not to be re-argued: the hook must never
|
|
24
|
+
* block or slow a developer's turn. "Later" means the bytes stay in the
|
|
25
|
+
* transcript file — which is where they already are — and the hook that fires on
|
|
26
|
+
* the next turn picks them up from the same offset.
|
|
27
|
+
*
|
|
28
|
+
* ## The table is data on purpose
|
|
29
|
+
*
|
|
30
|
+
* The title's word "table" is load-bearing. The classification is a literal data
|
|
31
|
+
* structure, so it is auditable by reading it and testable without exercising a
|
|
32
|
+
* real network failure. The original bug was an UNCLASSIFIED status silently
|
|
33
|
+
* defaulting to lossy behaviour, so the default here is explicit and it is
|
|
34
|
+
* `later` — the safe direction. A status nobody enumerated holds its bytes; it
|
|
35
|
+
* never discards them.
|
|
36
|
+
*
|
|
37
|
+
* This module is PURE. No fs, no network, no clock — `state.ts` persists, and the
|
|
38
|
+
* caller supplies `nowMs`. That is what lets both backlog caps be tested at their
|
|
39
|
+
* boundaries without waiting a day for one of them.
|
|
40
|
+
*/
|
|
41
|
+
import type { SendOutcome } from "./post.js";
|
|
42
|
+
export type FailureClass = "later" | "never" | "fatal";
|
|
43
|
+
/** What a completed attempt means. `ok` is the only non-failure. */
|
|
44
|
+
export type Disposition = "ok" | FailureClass;
|
|
45
|
+
/**
|
|
46
|
+
* THE TABLE. Status codes named one by one, because a family rule that swept up
|
|
47
|
+
* a whole `4xx` would be the implicit fallthrough this task exists to remove.
|
|
48
|
+
*
|
|
49
|
+
* Adding a row is how a new structurally-invalid status becomes `never`. Until
|
|
50
|
+
* someone does, an unnamed `4xx` resolves to `DEFAULT_CLASS` and HOLDS its bytes:
|
|
51
|
+
* a status we have not reasoned about is not one we discard data for.
|
|
52
|
+
*/
|
|
53
|
+
export declare const STATUS_CLASSES: ReadonlyMap<number, FailureClass>;
|
|
54
|
+
/** `5xx` — the server's problem, not the payload's. */
|
|
55
|
+
export declare const SERVER_ERROR_CLASS: FailureClass;
|
|
56
|
+
/** No HTTP response at all: DNS, refused, TLS, timeout, abort. */
|
|
57
|
+
export declare const NETWORK_CLASS: FailureClass;
|
|
58
|
+
/**
|
|
59
|
+
* The explicit default. **It is `later` and it must stay `later`.** An implicit
|
|
60
|
+
* fallthrough to advance-and-discard is the original defect at a smaller scale.
|
|
61
|
+
*/
|
|
62
|
+
export declare const DEFAULT_CLASS: FailureClass;
|
|
63
|
+
/** Classify one HTTP status. `2xx` is the only success. */
|
|
64
|
+
export declare function classifyStatus(status: number): Disposition;
|
|
65
|
+
/** Classify one attempt, response or not. */
|
|
66
|
+
export declare function classify(outcome: SendOutcome): Disposition;
|
|
67
|
+
/**
|
|
68
|
+
* The §13.6 copy for a class.
|
|
69
|
+
*
|
|
70
|
+
* `ERRORS` was scaffolded for this task and carries exactly these three lines, so
|
|
71
|
+
* this is the one place a class becomes a sentence. Nothing in the HOOK path
|
|
72
|
+
* calls it — a hook speaks only through `systemMessage` (§13.7) — but `CR-021`'s
|
|
73
|
+
* `status` needs a class-to-copy mapping and this is where it belongs rather than
|
|
74
|
+
* as a fourth `switch` in a verb body.
|
|
75
|
+
*/
|
|
76
|
+
export declare function failureCopy(cls: FailureClass): string;
|
|
77
|
+
/**
|
|
78
|
+
* One undelivered span of a transcript file.
|
|
79
|
+
*
|
|
80
|
+
* The bytes themselves are not copied anywhere: the transcript is append-only and
|
|
81
|
+
* still on disk, so a span is a coordinate pair, not a spool. `at` is when this
|
|
82
|
+
* span was FIRST left undelivered, which is what the age cap measures.
|
|
83
|
+
*/
|
|
84
|
+
export interface BacklogEntry {
|
|
85
|
+
/** Byte offset, inclusive. */
|
|
86
|
+
readonly from: number;
|
|
87
|
+
/** Byte offset, exclusive. */
|
|
88
|
+
readonly to: number;
|
|
89
|
+
/** Epoch ms of the first failed attempt covering this span. */
|
|
90
|
+
readonly at: number;
|
|
91
|
+
}
|
|
92
|
+
/** Per-(session, file) send state. `state.ts` persists it; this module decides it. */
|
|
93
|
+
export interface FileState {
|
|
94
|
+
/** Everything below this is delivered, or deliberately gapped. */
|
|
95
|
+
readonly sentOffset: number;
|
|
96
|
+
/** Contiguous undelivered spans, oldest first. `[0].from === sentOffset`. */
|
|
97
|
+
readonly backlog: readonly BacklogEntry[];
|
|
98
|
+
/** Bytes we advanced past without delivering. Counters, never a growing list. */
|
|
99
|
+
readonly gapBytes: number;
|
|
100
|
+
readonly gapCount: number;
|
|
101
|
+
/**
|
|
102
|
+
* Epoch ms of the last DELIVERED span, or 0 for "never" — `CR-021`.
|
|
103
|
+
*
|
|
104
|
+
* The only success clock in the store. `BacklogEntry.at` stamps a HELD span,
|
|
105
|
+
* i.e. a failure, so `status` had nothing honest to render before this.
|
|
106
|
+
*
|
|
107
|
+
* **0 means unknown, not the epoch.** A state file written before this field
|
|
108
|
+
* existed parses to 0 (`nonNegative`), and rendering that as 1 January 1970
|
|
109
|
+
* would be a confident lie about a value we simply do not have.
|
|
110
|
+
*/
|
|
111
|
+
readonly lastSentAt: number;
|
|
112
|
+
}
|
|
113
|
+
export declare const EMPTY_FILE_STATE: FileState;
|
|
114
|
+
/**
|
|
115
|
+
* The caps.
|
|
116
|
+
*
|
|
117
|
+
* ⚠ **Neither the register nor `DECISIONS.md` specifies an eviction policy once a
|
|
118
|
+
* cap is hit.** What is decided is only that both caps exist. The rule below is
|
|
119
|
+
* this task's choice, stated rather than smuggled: **drop the oldest span,
|
|
120
|
+
* advance past it, stamp a gap, continue.** It is the `never` class's own shape
|
|
121
|
+
* applied to our own storage, and it is the only option that neither grows
|
|
122
|
+
* unbounded nor blocks — the two things D56 §D8 forbids outright.
|
|
123
|
+
*
|
|
124
|
+
* `entries` is not a third policy. It is the same drop-oldest rule guarding the
|
|
125
|
+
* degenerate shape where a file grows one byte at a time: the byte cap alone
|
|
126
|
+
* would permit `MAX_BACKLOG_BYTES` single-byte entries.
|
|
127
|
+
*/
|
|
128
|
+
export declare const BACKLOG_CAPS: {
|
|
129
|
+
readonly bytes: number;
|
|
130
|
+
readonly ageMs: number;
|
|
131
|
+
readonly entries: 512;
|
|
132
|
+
};
|
|
133
|
+
export interface BacklogCaps {
|
|
134
|
+
readonly bytes: number;
|
|
135
|
+
readonly ageMs: number;
|
|
136
|
+
readonly entries: number;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Read the caps from the environment.
|
|
140
|
+
*
|
|
141
|
+
* Same shape as `hookBudgetMs`: an unset or unparseable value takes the default
|
|
142
|
+
* rather than disabling the cap, because a cap that an empty string can switch
|
|
143
|
+
* off is not a cap. Present for operators with unusual transcript volumes and
|
|
144
|
+
* for the tests, which have to reach a boundary they cannot otherwise reach.
|
|
145
|
+
*/
|
|
146
|
+
export declare function resolveCaps(env: NodeJS.ProcessEnv): BacklogCaps;
|
|
147
|
+
/** Undelivered bytes currently held. */
|
|
148
|
+
export declare function pendingBytes(state: FileState): number;
|
|
149
|
+
/**
|
|
150
|
+
* The span the next attempt should carry: everything from the offset to `eof`.
|
|
151
|
+
*
|
|
152
|
+
* A file SHORTER than the offset means the transcript was replaced or truncated —
|
|
153
|
+
* a new file at a path we have state for. Resync from 0 rather than reading a
|
|
154
|
+
* negative-length slice; the server is idempotent on `(session, offset)`.
|
|
155
|
+
*/
|
|
156
|
+
export declare function nextSpan(state: FileState, eof: number): {
|
|
157
|
+
from: number;
|
|
158
|
+
to: number;
|
|
159
|
+
} | null;
|
|
160
|
+
/**
|
|
161
|
+
* Everything up to `to` arrived. The backlog clears, and the success is STAMPED.
|
|
162
|
+
*
|
|
163
|
+
* `nowMs` is a parameter rather than a `Date.now()` call because this module is
|
|
164
|
+
* pure — that is what lets both backlog caps be tested at their boundaries
|
|
165
|
+
* without waiting a day, and the same property now covers the `last sent` row.
|
|
166
|
+
*/
|
|
167
|
+
export declare function markDelivered(state: FileState, to: number, nowMs: number): FileState;
|
|
168
|
+
/**
|
|
169
|
+
* `never` — advance PAST the attempted span exactly once, stamping a gap.
|
|
170
|
+
*
|
|
171
|
+
* Exactly once is the point: the offset lands at `to`, so the next invocation
|
|
172
|
+
* starts on the next, unrelated chunk. It neither loops on the poisoned bytes nor
|
|
173
|
+
* swallows what comes after them.
|
|
174
|
+
*/
|
|
175
|
+
export declare function markSkipped(state: FileState, from: number, to: number): FileState;
|
|
176
|
+
/**
|
|
177
|
+
* `later` — hold the offset and remember when this span first failed.
|
|
178
|
+
*
|
|
179
|
+
* The span is appended only when the file has actually grown past what is already
|
|
180
|
+
* held. A hook that fires twice on an unchanged file retries the same span rather
|
|
181
|
+
* than minting a second entry with a newer timestamp, which would keep resetting
|
|
182
|
+
* the age cap and make it unreachable.
|
|
183
|
+
*/
|
|
184
|
+
export declare function markHeld(state: FileState, span: {
|
|
185
|
+
from: number;
|
|
186
|
+
to: number;
|
|
187
|
+
}, nowMs: number, caps: BacklogCaps): FileState;
|
|
188
|
+
/**
|
|
189
|
+
* Shed the oldest spans until every cap holds.
|
|
190
|
+
*
|
|
191
|
+
* Never blocks, never grows. Each shed span advances the offset past itself and
|
|
192
|
+
* stamps a gap, so what the server sees is a discontinuity in `byteOffset` — an
|
|
193
|
+
* honest hole — and not a silent renumbering.
|
|
194
|
+
*/
|
|
195
|
+
export declare function enforceCaps(state: FileState, nowMs: number, caps: BacklogCaps): FileState;
|