dsh-diff-approval 0.29.1 → 0.30.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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Where a comment's quote sits in a file's CURRENT content: the rule that turns the lines a comment
3
+ * was WRITTEN on into the lines it is about now.
4
+ *
5
+ * Pure and free of both Node and the DOM, so the two halves of the plugin import the very same rule:
6
+ * the host resolves each comment against the entry content it hangs off and ships the figures on the
7
+ * list read, and the browser half draws them. The panel's own row-level re-anchor
8
+ * (`src/client/discussion.ts`) is this same rule seen through the diff's rows — with one row per
9
+ * line, a removed line among them, and the quote's context taken from the rows — and it stays the
10
+ * browser's business: where a card is DRAWN is a layout fact. What a reader is TOLD (the list's
11
+ * label, the card's own chip, the jump's target line) comes from here, once, so two panes cannot
12
+ * name two lines for one comment.
13
+ *
14
+ * The two models can disagree in one situation, and it is worth knowing which: the host has the
15
+ * file's own lines, while the client's rows also carry the lines this file DELETED. A quote whose
16
+ * recorded `quoteContext` was captured across a deleted row therefore matches a context window the
17
+ * host cannot reproduce, and the host answers `undefined` where the client would re-anchor. The
18
+ * caller then falls back to `record.anchor` — the line the comment was written on — which is what an
19
+ * outdated thread shows anyway: the number stays honest rather than drifting.
20
+ *
21
+ * What the two may NOT disagree about is how far a match is allowed to be from the stored line: a
22
+ * look-alike elsewhere in the file is refused by both, through the one bound they share
23
+ * (`REANCHOR_MAX_LINES`), so the list's figure, the card's chip and the card's place cannot name two
24
+ * different lines for one comment.
25
+ * @module dsh-diff-approval/comment-lines
26
+ */
27
+ /** The new-file lines one comment's quote settles on now (1-based, inclusive). */
28
+ export interface CommentLineRange {
29
+ /** First new-file line the quote occupies in this content. */
30
+ start: number;
31
+ /** Last one. A one-line quote has `end === start`. */
32
+ end: number;
33
+ }
34
+ /**
35
+ * The record fields resolution reads: everything a re-anchor needs and nothing else.
36
+ *
37
+ * Structurally satisfied by `CommentRecord`, so a caller passes the record itself. The reader's own
38
+ * words, the questions asked and the timestamps play no part in where the code is.
39
+ */
40
+ export interface CommentLineSource {
41
+ /** The lines the annotation was made on, as new-file numbers. */
42
+ anchor: {
43
+ startLine: number;
44
+ endLine: number;
45
+ };
46
+ /** The anchored lines as they read when the comment was written. */
47
+ quote: string;
48
+ /**
49
+ * `quote` with one line of context on each side, as it read then, when the record kept one. It is
50
+ * what tells a genuine move of the quoted code from another line elsewhere in the file that
51
+ * happens to read the same — without it a comment on a blank line or a closing brace followed that
52
+ * line wherever it appeared.
53
+ */
54
+ quoteContext?: string | undefined;
55
+ }
56
+ /**
57
+ * One file's content lines, numbered exactly as the diff numbers them.
58
+ *
59
+ * Both line endings are normalized first (`\r\n` and a lone `\r` become `\n`), and a single trailing
60
+ * newline is a line TERMINATOR rather than an extra empty line — the same convention the browser
61
+ * half's whole-file diff splits on (its `contentLines`), so line 1 here is the line the diff calls
62
+ * line 1 and the two cannot drift by a trailing newline.
63
+ *
64
+ * @param text - the file's content.
65
+ * @returns its lines, without the terminating newline; an empty file has none.
66
+ */
67
+ export declare function commentFileLines(text: string): string[];
68
+ /**
69
+ * How far a comment's quoted code may sit from the line the comment was written on and still count as
70
+ * that code having MOVED, in lines.
71
+ *
72
+ * A comment is re-anchored by its quote, and a quote is not unique: two declarations in one file can
73
+ * read exactly alike. The recorded context tells such places apart only as far as it reaches — one
74
+ * line each side — which two identically-shaped fields (`UPROPERTY` line, field, blank line) defeat
75
+ * completely. Distance is the other half of the answer, and the half that does not depend on what the
76
+ * record happened to keep: this code MOVING is a shift of a few lines, the insert or delete directly
77
+ * above it that every edit produces, while a match tens or hundreds of lines away is another place in
78
+ * the file that reads the same. The report's two cases bracket the number: the same declaration moved
79
+ * four lines when four lines above it were deleted, and a declaration that had been DELETED matched an
80
+ * identically-shaped one 74 lines below. Thirty keeps the first and refuses the second.
81
+ *
82
+ * Thirty is a screenful of code rather than a fact about the code: farther than that the reader is
83
+ * looking at another part of the file, and following it is a teleport rather than a correction — the
84
+ * comment ends up shown against code it was never written about, which is the bug this bound fixes.
85
+ * Any bound between the two real cases separates them; this one leaves a genuine shift of a
86
+ * function-sized block room to be followed, and nothing more.
87
+ *
88
+ * Both halves compare against this one number: the host in the file's own lines, the browser half in
89
+ * the rows of its diff model (a row is a line, or a line the file no longer has). A row distance is
90
+ * never smaller than the line distance it stands for, so a match the browser half refuses is one the
91
+ * host refused too, and the two panes cannot name a line the other will not draw.
92
+ */
93
+ export declare const REANCHOR_MAX_LINES = 30;
94
+ /**
95
+ * Whether a match that far from where the comment was written is that code having moved.
96
+ *
97
+ * The rule the two halves share, so neither can widen or narrow the bound on its own (see
98
+ * `REANCHOR_MAX_LINES` for the number and for what it separates). The bound is inclusive: at exactly
99
+ * the limit the code is taken to have moved.
100
+ *
101
+ * @param stored - the line (the host's content) or row (the browser half's model) the comment names.
102
+ * @param found - the line or row the quote was found on.
103
+ * @returns whether that match is close enough to be the same code.
104
+ */
105
+ export declare function reanchorWithinLimit(stored: number, found: number): boolean;
106
+ /**
107
+ * Where one comment's quote sits in a file's current content, or `undefined` when it is nowhere.
108
+ *
109
+ * The rule, in the order it is applied — the same order the browser half's `remapDiscussion` applies
110
+ * it in, so the same file and the same record produce the same number in both halves:
111
+ *
112
+ * 1. The lines the comment names have to exist in this file at all. When even the anchor's first
113
+ * line is past the end, nothing here can be re-anchored: `undefined`.
114
+ * 2. The anchor's own line still reads the quote: the comment has not moved. The stored range is the
115
+ * answer, `endLine` included — a range whose quote covers a different number of lines (a removed
116
+ * row was quoted among them) still belongs to the reader's own numbers.
117
+ * 3. Otherwise the quote is followed: every window of as many lines as the quote has is compared to
118
+ * it, the recorded context has to agree where the record carries one, and the occurrence CLOSEST
119
+ * to the stored anchor wins — that is where the reader last saw the code. An equal distance keeps
120
+ * the earlier occurrence, which is the first window an ascending scan meets.
121
+ * 4. The occurrence also has to be NEAR the stored anchor (see `REANCHOR_MAX_LINES`): a window
122
+ * farther than that from the line the comment was written on is not this code having moved but
123
+ * another place in the file that reads the same — however exactly its recorded context agrees.
124
+ * 5. Nothing matched: `undefined`. The caller falls back to `record.anchor`, the line the comment was
125
+ * written on — the same answer the code view gives an outdated thread.
126
+ *
127
+ * @param text - the entry's current content (the file as it reads now).
128
+ * @param comment - the stored comment's anchor and quote.
129
+ * @returns the new-file line range the quote occupies now, or `undefined` when it is gone.
130
+ */
131
+ export declare function resolveCommentLines(text: string, comment: CommentLineSource): CommentLineRange | undefined;
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Durable comment threads, one JSON file per session under
3
+ * `<commentsDir>/<sessionId>.json`, beside the pending-entry storage but never in
4
+ * the same file: a comment that outlived its entry is exactly the leak this
5
+ * module exists to prevent, so a pending write that fails must not be able to
6
+ * take comments with it (or the other way round).
7
+ *
8
+ * A comment belongs to ONE pending entry (`entryId`, which is the entry's path —
9
+ * the store's global identity) and to ONE session. It carries the annotation and
10
+ * the identity of the turn that answered it (`messageId`, `turn`); it does NOT
11
+ * carry the answer's text, because the session transcript stays the single source
12
+ * of truth for what the agent said — a client reads the answer out of the
13
+ * transcript by that id. Saves rewrite the whole session file, staged as a sibling
14
+ * temp file and atomically renamed, so a crash leaves either the old or the new
15
+ * file. A missing file reads as empty; unknown versions and non-JSON content throw.
16
+ * @module dsh-diff-approval/src/comments
17
+ */
18
+ import type { SessionId } from '@deepseek-ai/dsh-session/types';
19
+ import type { CommentRecord } from './types.ts';
20
+ /**
21
+ * The comment directory that belongs to a pending-storage root.
22
+ *
23
+ * The plugin's own state directory is `<dshHome>/diff-approval`, and inside it the
24
+ * pending file lives in `workspaces/` and comments in `comments/` — siblings, so a
25
+ * write that fails on one side cannot take the other with it. Any OTHER root is a
26
+ * self-contained state directory (a deployment's chosen location, a test's temp
27
+ * dir) and keeps its comments INSIDE it: the sibling of an arbitrary directory can
28
+ * be shared by two instances that were each pointed somewhere different, and two
29
+ * instances writing one comment file is a leak rather than a layout choice.
30
+ *
31
+ * @param storageRoot - the resolved pending-storage root.
32
+ * @returns the directory holding the comments that belong to that root.
33
+ */
34
+ export declare function commentsDirFor(storageRoot: string): string;
35
+ /**
36
+ * How a caller hears about the two ways a comment file can go wrong.
37
+ *
38
+ * The store reads and writes plain JSON files and owns no logger, so both halves are
39
+ * callbacks: the host wires them to `ctx.logger` and to the list read the panel draws,
40
+ * which is what turns "the write failed" from a fact only the filesystem knew into
41
+ * something the reader is told (and something a test can assert without a disk).
42
+ */
43
+ export interface CommentStoreOptions {
44
+ /**
45
+ * One session's comments could not be read, and that file was skipped. Called once per
46
+ * skipped file, with the file's path, because the caller's warning has to name it.
47
+ */
48
+ onLoadSkipped?: (file: string, error: unknown) => void;
49
+ /**
50
+ * A save failed (`message` is the reason) or a session's failure cleared (`undefined`).
51
+ * Called only when the report CHANGES, so a writer retrying every second cannot bury
52
+ * the log; the same value also rides the list read as `persistError()`. One session's
53
+ * healthy write does not clear another's failure — see `persistError`.
54
+ */
55
+ onPersistError?: (message: string | undefined) => void;
56
+ }
57
+ /**
58
+ * File-backed comment threads, one file per session.
59
+ *
60
+ * Every mutation is applied to memory synchronously and schedules that session's
61
+ * file write on its own chain, so a caller removing an entry can drop its comments
62
+ * in the same tick as the entry itself without awaiting anything — the same
63
+ * fire-and-forget persistence the pending store uses.
64
+ */
65
+ export declare class CommentStore {
66
+ private readonly root;
67
+ private readonly options;
68
+ private readonly byId;
69
+ private readonly tails;
70
+ private revision;
71
+ /** Files `loadAll` could not read, in the order it met them (see `skippedFiles`). */
72
+ private readonly skipped;
73
+ /**
74
+ * The save failure of every session that has one, keyed by that session's file and in the
75
+ * order the failures happened (see `persistError`). Empty while every write works.
76
+ */
77
+ private readonly persistFailures;
78
+ /**
79
+ * Sessions whose unreadable file could not even be moved aside, keyed by that file: the
80
+ * reason their writes are refused rather than allowed to truncate the bytes (see `save`).
81
+ */
82
+ private readonly unmovable;
83
+ /**
84
+ * @param root - directory holding one `<sessionId>.json` per session.
85
+ * @param options - the error seam (see {@link CommentStoreOptions}).
86
+ */
87
+ constructor(root: string, options?: CommentStoreOptions);
88
+ /**
89
+ * Load every session's comments. A malformed row is skipped rather than
90
+ * failing the load: one bad comment must not cost the whole workspace's
91
+ * comments (or the plugin's activation).
92
+ *
93
+ * A file that cannot be READ is skipped the same way, because the alternative is
94
+ * worse in both directions: the old all-or-nothing load threw, the caller logged
95
+ * once and carried on with an EMPTY store, and the next save then wrote that
96
+ * emptiness back over every good file. One unreadable file therefore cost every
97
+ * session's comments. Skipping keeps the other sessions' threads, and the skipped
98
+ * file is moved aside (`.corrupt`) rather than dropped: it is not deleted — the
99
+ * bytes may be the only copy — but it is also not left where the next save for
100
+ * that session would silently overwrite it, which would destroy them for good.
101
+ *
102
+ * When even that fails — the file cannot be moved aside — that session's writes are
103
+ * refused from then on (see `save`) rather than allowed to truncate the only copy.
104
+ * "Skipped but still writable" is exactly the shape that turns a read failure into
105
+ * data loss: the store would answer "added", write the memory-only view over bytes
106
+ * nobody could read, and lose what the skip had just refused to guess at.
107
+ * @returns how many comments were loaded.
108
+ */
109
+ loadAll(): Promise<number>;
110
+ /**
111
+ * The files a load skipped, so the caller can say what went missing instead of
112
+ * reporting a session whose comments silently vanished.
113
+ * @returns the skipped files' paths, in load order.
114
+ */
115
+ skippedFiles(): string[];
116
+ /**
117
+ * The failure the reader has to be told about, or `undefined` while every write works.
118
+ * This is what rides the list read so the panel can say the threads are memory-only;
119
+ * a later successful save retracts it, mirroring the pending half's own report.
120
+ *
121
+ * Per SESSION, not one global slot: a deployment where one session's file cannot be
122
+ * written and another's can is the ordinary case (a locked file, a full quota, a
123
+ * directory that vanished), and letting the healthy write retract the broken one's
124
+ * report would say the threads are safe while that session's live in memory only —
125
+ * the same "a failure that looks like a success" trap, one session over. A session's
126
+ * own success is what retracts its own entry (see `reportPersist`).
127
+ * @returns the most recent outstanding failure's message, or undefined.
128
+ */
129
+ persistError(): string | undefined;
130
+ /**
131
+ * Record a skipped file: keep it for the caller, name it, and move it aside so a later
132
+ * save for that session writes a fresh file rather than overwriting the unreadable
133
+ * bytes. A quarantine that itself fails is still a skip — the store must not be the
134
+ * reason a single bad file takes the whole load down — but that session's file is then
135
+ * the only copy of its threads, so its writes are refused (see `save`) instead of being
136
+ * allowed to truncate it.
137
+ * @param file - the file that could not be read.
138
+ * @param error - why it could not be read.
139
+ */
140
+ private skip;
141
+ /** One session's comments, oldest first (the order threads are read in). */
142
+ list(sessionId: SessionId): CommentRecord[];
143
+ /** A comment by id, whatever session it belongs to. */
144
+ get(id: string): CommentRecord | undefined;
145
+ /**
146
+ * A counter bumped on every change, so a poller can tell "nothing new" from
147
+ * "re-read": the list endpoint ships the session's comments anyway, and this is
148
+ * what says whether anything moved since the last read.
149
+ */
150
+ commentsRevision(): number;
151
+ /**
152
+ * Record one annotation.
153
+ *
154
+ * Writing a comment that is already here is the SAME comment written again — a client
155
+ * retrying a request whose response was dropped, or a second client sending the id it
156
+ * was handed — so the questions already asked in it are kept and only the annotation's
157
+ * own fields move. Overwriting the whole record would erase the identity of a question
158
+ * that is in flight, and its answer could then never be matched back to this thread;
159
+ * `createdAt` is kept for the same reason, since it is what a list read orders by.
160
+ *
161
+ * @param record - the comment to store.
162
+ * @returns the stored comment, which is the existing one when the id was already here.
163
+ */
164
+ add(record: CommentRecord): CommentRecord;
165
+ /**
166
+ * Record several annotations at once, as ONE write per affected session.
167
+ *
168
+ * This is what putting a batch of restored comments back needs and what a loop of `add` calls
169
+ * cannot give it: each `add` saves its session's file, so an undo that restored ten threads wrote
170
+ * that file ten times — ten chances for a transient failure to leave the store and the disk
171
+ * disagreeing about an action the reader already watched happen. The records are stored by the SAME
172
+ * rule as `add` (a new id verbatim, an existing one merged with its `createdAt` and its questions
173
+ * kept), which is what lets a restore be an ordinary write rather than a special case: a snapshot
174
+ * that came back as a new comment, with a fresh id and no questions, would be a look-alike of the
175
+ * reader's thread.
176
+ *
177
+ * A batch that carries nothing changes nothing, so a no-op restore does not rewrite a file.
178
+ *
179
+ * @param records - the comments to store, in the caller's order.
180
+ * @returns the stored record for each input, index-aligned: what landed, which is not always what
181
+ * was handed over (a merge keeps the fields the existing comment owns). A count would drop that,
182
+ * and a caller needing it would have to `get` each id back and race a later change.
183
+ */
184
+ addMany(records: readonly CommentRecord[]): CommentRecord[];
185
+ /**
186
+ * The record as the store keeps it: a new id VERBATIM, an id already here merged.
187
+ *
188
+ * The one place the merge rule lives, so `add` and `addMany` cannot drift: writing a comment that
189
+ * is already here is the same comment written again — a client retrying a request whose response
190
+ * was dropped, or a second client sending the id it was handed — so the questions already asked in
191
+ * it are kept and only the annotation's own fields move. Overwriting the whole record would erase
192
+ * the identity of a question that is in flight, and its answer could then never be matched back to
193
+ * this thread; `createdAt` is kept for the same reason, since it is what a list read orders by.
194
+ * @param record - the comment as the caller wrote it.
195
+ * @returns the record to store under its id.
196
+ */
197
+ private mergeOf;
198
+ /**
199
+ * Drop one comment.
200
+ * @param sessionId - the session the caller believes the comment belongs to.
201
+ * @param id - the comment to drop.
202
+ * @returns whether it was there (and belonged to that session).
203
+ */
204
+ remove(sessionId: SessionId, id: string): boolean;
205
+ /**
206
+ * Drop several comments of one session in ONE write.
207
+ *
208
+ * This is what a batch action needs and what a loop of `remove` calls cannot give it: each
209
+ * `remove` saves the session's file, so ending ten comments wrote that file ten times — ten
210
+ * chances for a transient failure to leave the store and the disk disagreeing about an action
211
+ * the reader asked for once. The save happens only when something was actually dropped, so a
212
+ * batch that matches nothing does not rewrite the file.
213
+ *
214
+ * An id of another session, or one that is not here at all, is skipped rather than refused:
215
+ * a batch is one request and the comments it names are the ones it can act on, and a comment
216
+ * another client already ended is the state the caller was asking for anyway.
217
+ *
218
+ * @param sessionId - the session the caller believes the comments belong to.
219
+ * @param ids - the comments to drop; a repeated id is one comment.
220
+ * @returns the ids that were dropped, in the order given.
221
+ */
222
+ removeMany(sessionId: SessionId, ids: readonly string[]): string[];
223
+ /**
224
+ * Every comment one pending entry carries, oldest first.
225
+ *
226
+ * The read half of `removeForEntry` and kept beside it, because they are two answers about the same
227
+ * fact: which comments an entry owns. A caller that is about to drop the entry asks this first, so
228
+ * the undo pair can carry them back; the two must agree on that set, or a restore would put back
229
+ * fewer (or more) threads than the drop took.
230
+ * @param entryId - the entry id (= path).
231
+ * @returns that entry's comments, in list order.
232
+ */
233
+ forEntry(entryId: string): CommentRecord[];
234
+ /**
235
+ * Drop every comment of one pending entry. This is what "a comment dies with
236
+ * its entry" means, and the entry knows nothing about comments, so the caller
237
+ * runs it beside the entry's own removal.
238
+ * @param entryId - the entry id (= path) that left the list.
239
+ * @returns how many comments went with it.
240
+ */
241
+ removeForEntry(entryId: string): number;
242
+ /**
243
+ * The orphan sweep: drop every comment whose entry is not in `entryIds`.
244
+ *
245
+ * The explicit `removeForEntry` is the primary path; this is the backstop that
246
+ * catches a comment left behind by a crash between the two writes. Run it when
247
+ * the store loads and on every list read, and the one race it could lose — an
248
+ * entry re-added after its comments were already swept — cannot happen: entries
249
+ * only leave through this host, so an entry absent at load time was removed
250
+ * while the host was running, and the sweep at load runs before anything can
251
+ * add it back.
252
+ * @param entryIds - every entry id the pending store currently holds.
253
+ * @returns how many orphaned comments were dropped.
254
+ */
255
+ retain(entryIds: ReadonlySet<string>): number;
256
+ /**
257
+ * Record one question asked in a thread. The answer itself is never stored, and the
258
+ * request id is minted by the caller before it submits, so this runs as part of the
259
+ * ask rather than waiting for the session to answer. A thread keeps its questions in
260
+ * order: the first is the annotation's own, and each follow-up appends.
261
+ *
262
+ * The reader's own words are stored WITH the question (`text`), because they exist
263
+ * nowhere else: the transcript holds the prompt, which is those words wrapped in the
264
+ * marker, the reference and the rules, and a thread that showed the prompt would show
265
+ * the scaffolding rather than the question.
266
+ *
267
+ * @param sessionId - the session the comment belongs to.
268
+ * @param id - the comment that was asked in.
269
+ * @param requestId - the identity the submission carries into the transcript.
270
+ * @param turn - the turn that claimed it, when that is already known.
271
+ * @param text - the reader's own words, without the prompt's wrapper.
272
+ * @returns whether the comment existed.
273
+ */
274
+ recordAsk(sessionId: SessionId, id: string, requestId: string, turn: number | undefined, text: string): boolean;
275
+ /**
276
+ * Record the turn that claimed one question, as the agent's inbox reported it. Two
277
+ * questions claimed by the same turn were answered together, which is a fact the
278
+ * panel can state instead of guessing from transcript positions.
279
+ * @param sessionId - the session the submission belongs to.
280
+ * @param requestId - the request identity the inbox reported back.
281
+ * @param turn - the turn that claimed it.
282
+ * @returns how many threads the patch touched.
283
+ */
284
+ recordTurnForRequest(sessionId: SessionId, requestId: string, turn: number): number;
285
+ /**
286
+ * Record that the session dropped one question before a turn claimed it: nothing is
287
+ * coming, and the reader is told rather than left waiting.
288
+ * @param sessionId - the session the submission belongs to.
289
+ * @param requestId - the request identity the inbox reported back.
290
+ * @returns how many threads the patch touched.
291
+ */
292
+ markDroppedForRequest(sessionId: SessionId, requestId: string): number;
293
+ /**
294
+ * Record that a turn is over, so the questions it claimed stop looking pending.
295
+ *
296
+ * The turn number is the only handle the stopping event carries — no message, no
297
+ * request id — so the questions that were claimed by that turn are the ones patched
298
+ * (see `recordTurnForRequest`, which wrote the number down when the inbox claimed
299
+ * them). A question whose turn is over and that the transcript shows no answer for
300
+ * was cut off; the client says so instead of waiting forever. Nothing here decides
301
+ * that: whether an answer exists is read from the transcript on each list read, so a
302
+ * turn that stopped AFTER writing its answer keeps showing the answer.
303
+ *
304
+ * @param sessionId - the session whose turn ended.
305
+ * @param turn - the turn that stopped.
306
+ * @returns how many threads the patch touched.
307
+ */
308
+ markTurnEnded(sessionId: SessionId, turn: number): number;
309
+ /**
310
+ * Apply one patch to every comment it matches, and schedule the affected sessions'
311
+ * writes. The one mutation seam, so no caller has to remember to bump the revision
312
+ * or to save: a patch that matches nothing changes nothing.
313
+ * @param match - which comments to patch.
314
+ * @param patch - the change to apply.
315
+ * @returns how many comments changed.
316
+ */
317
+ private patch;
318
+ /**
319
+ * Wait for every scheduled write to settle.
320
+ *
321
+ * The mutators return as soon as a change is in memory — that is what lets an
322
+ * entry's removal take its comments with it in the same tick — so this is how a
323
+ * caller that must not lose the write (a dispose path, a test asserting the file)
324
+ * waits for the disk.
325
+ * @returns resolution after every session file's write chain has drained.
326
+ */
327
+ settled(): Promise<void>;
328
+ /**
329
+ * Write one session's comments, serialized against that file's own writes.
330
+ *
331
+ * A rejected write is reported, not swallowed: the mutation already answered
332
+ * "added" and the panel is drawing the thread from memory, so a silent failure means
333
+ * the reader keeps writing into a thread that a restart will erase. The report rides
334
+ * the list read (`persistError`) and the caller's logger, and a later write that works
335
+ * retracts it the way the pending half retracts its own.
336
+ *
337
+ * A session whose file could not be read at load AND could not be moved aside is not
338
+ * written at all: the only copy of that session's threads is the bytes the load could
339
+ * not parse, and the store's memory holds just what has been added since. Writing the
340
+ * memory-only view would TRUNCATE that copy — turning a read failure into a permanent
341
+ * loss — so the save is refused and reported instead, which is what tells the reader
342
+ * those comments are memory-only.
343
+ * @param sessionId - the session whose file to rewrite.
344
+ */
345
+ private save;
346
+ /**
347
+ * Publish the write state of one session's file, once per distinct report. Kept as its own
348
+ * step so both the success and the failure of the chain above land in one place — the
349
+ * retraction is what arms a later failure to be reported again.
350
+ *
351
+ * Keyed per file, so a healthy session's write cannot retract a broken one's report (see
352
+ * `persistError`); the callback sees the aggregate, which is what rides the list read.
353
+ * @param file - the session file the write was for.
354
+ * @param message - the failure's text, or undefined when the write worked.
355
+ */
356
+ private reportPersist;
357
+ }
@@ -35,9 +35,11 @@
35
35
  */
36
36
  import { Context } from '@deepseek-ai/cordis';
37
37
  import type { DiffApprovalOpenAction } from './types.ts';
38
- export type { DiffApprovalActionOutcome, DiffApprovalActionValue, DiffApprovalBlockRange, DiffApprovalBlockTarget, DiffApprovalListValue, DiffApprovalOpenAction, DiffApprovalOpenValue, DiffApprovalRefreshOutcome, DiffApprovalRefreshValue, PendingEntry, PendingEntryKind, PendingFileDiff, } from './types.ts';
38
+ export type { DiffApprovalActionOutcome, DiffApprovalActionValue, DiffApprovalBlockRange, DiffApprovalBlockTarget, DiffApprovalListValue, DiffApprovalOpenAction, DiffApprovalOpenValue, DiffApprovalRefreshOutcome, DiffApprovalRefreshValue, CommentAnchor, CommentQuoteLine, CommentRecord, DiffApprovalCommentAddValue, DiffApprovalCommentRemoveManyValue, DiffApprovalCommentRemoveValue, PendingEntry, PendingEntryKind, PendingFileDiff, } from './types.ts';
39
39
  export { PendingDiffStore } from './pending.ts';
40
40
  export { PendingPersistence, defaultStorageDir } from './persist.ts';
41
+ export { CommentStore, commentsDirFor } from './comments.ts';
42
+ export { CommentAsker, answerForRequest } from './comment-ask.ts';
41
43
  export { defaultOpenPath } from './open.ts';
42
44
  /** Stable Cordis plugin name. */
43
45
  export declare const name = "diff-approval";
@@ -45,7 +47,10 @@ export declare const name = "diff-approval";
45
47
  * Services required before the review surface activates. `webServer` rides with
46
48
  * `connection` (both come from the web bundle), and is named explicitly because
47
49
  * the channel's owner context must be able to resolve it — see
48
- * `ConnectionServiceSurface`.
50
+ * `ConnectionServiceSurface`. `sessionController` and `agents` are what a comment is
51
+ * asked through and what reports the turn that claimed it: both come from the same
52
+ * base composition this plugin already requires for `sessions`, so a host that could
53
+ * list pending changes at all has them.
49
54
  */
50
55
  export declare const inject: string[];
51
56
  /** The connection RPC channel this plugin serves. */
@@ -18,6 +18,29 @@ import type { PendingEntry } from './types.ts';
18
18
  */
19
19
  export declare class PendingDiffStore {
20
20
  private readonly entries;
21
+ /**
22
+ * The content version of each entry: bumped whenever its tracked `newText` moves, and never for a
23
+ * change that leaves the content alone (a block keep advances `oldText` only).
24
+ *
25
+ * It is what lets derived, content-dependent state — the lines a comment's quote resolves to, read
26
+ * off `newText` — be cached and refreshed when the content moved rather than recomputed on every
27
+ * read. The versions come from one monotonic counter and are never reused, so an entry that leaves
28
+ * the list and comes back cannot hand back the number a stale cache was keyed to.
29
+ */
30
+ private readonly contentVersions;
31
+ /** The last version handed out; only ever climbs (see `contentVersions`). */
32
+ private contentVersionCounter;
33
+ /** Note that one path's tracked content moved, and give it a fresh version. */
34
+ private bumpContent;
35
+ /**
36
+ * The version of one path's tracked content, or 0 while it has none.
37
+ *
38
+ * Two calls that return the same number describe the same `newText`: a caller caching something
39
+ * derived from that content may serve its copy until the number changes.
40
+ * @param path - the file path (the global entry key).
41
+ * @returns the path's content version.
42
+ */
43
+ contentVersion(path: string): number;
21
44
  /**
22
45
  * Merge one captured operation into its file's entry. A no-op (equal before
23
46
  * and after) folds nothing.