dsh-diff-approval 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +7 -4
  2. package/README.zh.md +7 -4
  3. package/assets/fonts/README.md +10 -8
  4. package/assets/fonts/manifest.json +1 -1
  5. package/lib/client.js +5161 -1972
  6. package/lib/index.js +4331 -442
  7. package/lib/types/annotate-skill.d.ts +34 -0
  8. package/lib/types/annotate-tool.d.ts +227 -0
  9. package/lib/types/annotate.d.ts +186 -0
  10. package/lib/types/atomic-write.d.ts +77 -0
  11. package/lib/types/client/PendingPanel.d.ts +92 -24
  12. package/lib/types/client/category-color.d.ts +62 -0
  13. package/lib/types/client/chords.d.ts +70 -17
  14. package/lib/types/client/discussion.d.ts +76 -16
  15. package/lib/types/client/dock.d.ts +8 -8
  16. package/lib/types/client/dsh-icons.d.ts +98 -0
  17. package/lib/types/client/header-entry.d.ts +27 -11
  18. package/lib/types/client/ids.d.ts +18 -0
  19. package/lib/types/client/locales.d.ts +88 -36
  20. package/lib/types/client/panel-memory.d.ts +118 -35
  21. package/lib/types/client/path-match.d.ts +50 -0
  22. package/lib/types/client/port.d.ts +77 -1
  23. package/lib/types/client/presentation-menu.d.ts +1 -1
  24. package/lib/types/client/produced-diff.d.ts +136 -22
  25. package/lib/types/client/reference.d.ts +0 -2
  26. package/lib/types/client/remap-sync.d.ts +18 -1
  27. package/lib/types/client/session-seat.d.ts +93 -0
  28. package/lib/types/client/settings.d.ts +40 -14
  29. package/lib/types/client/slots.d.ts +184 -12
  30. package/lib/types/client/store.d.ts +85 -2
  31. package/lib/types/comment-ask.d.ts +236 -0
  32. package/lib/types/comment-lines.d.ts +131 -0
  33. package/lib/types/comments.d.ts +465 -0
  34. package/lib/types/index.d.ts +7 -2
  35. package/lib/types/pending.d.ts +53 -4
  36. package/lib/types/types.d.ts +356 -4
  37. package/lib/types/vcs.d.ts +53 -26
  38. package/lib/types/version.d.ts +162 -0
  39. package/package.json +7 -2
  40. package/lib/types/client/chat-bridge.d.ts +0 -70
@@ -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,465 @@
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 { CommentAsk, CommentRecord } from './types.ts';
20
+ import type { AskRead } from './comment-ask.ts';
21
+ /**
22
+ * The comment directory that belongs to a pending-storage root.
23
+ *
24
+ * The plugin's own state directory is `<dshHome>/diff-approval`, and inside it the
25
+ * pending file lives in `workspaces/` and comments in `comments/` — siblings, so a
26
+ * write that fails on one side cannot take the other with it. Any OTHER root is a
27
+ * self-contained state directory (a deployment's chosen location, a test's temp
28
+ * dir) and keeps its comments INSIDE it: the sibling of an arbitrary directory can
29
+ * be shared by two instances that were each pointed somewhere different, and two
30
+ * instances writing one comment file is a leak rather than a layout choice.
31
+ *
32
+ * @param storageRoot - the resolved pending-storage root.
33
+ * @returns the directory holding the comments that belong to that root.
34
+ */
35
+ export declare function commentsDirFor(storageRoot: string): string;
36
+ /**
37
+ * The scope one request may act on: which comment AUTHORS its lineage covers.
38
+ *
39
+ * This module knows nothing about lineages — the host walks them — so the rule arrives as a predicate,
40
+ * built once per request from the lineage view the endpoint already has (`sameRoot`). Every read and
41
+ * every guard takes it, which is what lets the store answer for a whole lineage without a second
42
+ * lineage rule living down here beside the first.
43
+ */
44
+ export type CommentScope = (author: SessionId) => boolean;
45
+ /**
46
+ * The transcript one question's answer will be written in.
47
+ *
48
+ * `ask.sessionId` when the question recorded one — every question asked since a lineage could read more
49
+ * than one seat's threads does — otherwise the comment's own session, which is where a question asked
50
+ * before that field existed necessarily went: the only session that could ask it was the one that wrote
51
+ * the thread. ONE definition, shared by the answer fold, the turn-end match and the asker's grouping,
52
+ * so the three cannot disagree about which transcript a question belongs to.
53
+ * @param ask - the question.
54
+ * @param comment - the thread it was asked in.
55
+ * @returns the session whose transcript holds its answer.
56
+ */
57
+ export declare function askTranscript(ask: CommentAsk, comment: CommentRecord): SessionId;
58
+ /**
59
+ * How a caller hears about the two ways a comment file can go wrong.
60
+ *
61
+ * The store reads and writes plain JSON files and owns no logger, so both halves are
62
+ * callbacks: the host wires them to `ctx.logger` and to the list read the panel draws,
63
+ * which is what turns "the write failed" from a fact only the filesystem knew into
64
+ * something the reader is told (and something a test can assert without a disk).
65
+ */
66
+ export interface CommentStoreOptions {
67
+ /**
68
+ * One session's comments could not be read, and that file was skipped. Called once per
69
+ * skipped file, with the file's path, because the caller's warning has to name it.
70
+ */
71
+ onLoadSkipped?: (file: string, error: unknown) => void;
72
+ /**
73
+ * A save failed (`message` is the reason) or a session's failure cleared (`undefined`).
74
+ * Called only when the report CHANGES, so a writer retrying every second cannot bury
75
+ * the log; the same value also rides the list read as `persistError()`. One session's
76
+ * healthy write does not clear another's failure — see `persistError`.
77
+ */
78
+ onPersistError?: (message: string | undefined) => void;
79
+ }
80
+ /**
81
+ * File-backed comment threads, one file per session.
82
+ *
83
+ * Every mutation is applied to memory synchronously and schedules that session's
84
+ * file write on its own chain, so a caller removing an entry can drop its comments
85
+ * in the same tick as the entry itself without awaiting anything — the same
86
+ * fire-and-forget persistence the pending store uses.
87
+ */
88
+ export declare class CommentStore {
89
+ private readonly root;
90
+ private readonly options;
91
+ private readonly byId;
92
+ private readonly tails;
93
+ private revision;
94
+ /** Files `loadAll` could not read, in the order it met them (see `skippedFiles`). */
95
+ private readonly skipped;
96
+ /**
97
+ * The save failure of every session that has one, keyed by that session's file and in the
98
+ * order the failures happened (see `persistError`). Empty while every write works.
99
+ */
100
+ private readonly persistFailures;
101
+ /**
102
+ * Sessions whose unreadable file could not even be moved aside, keyed by that file: the
103
+ * reason their writes are refused rather than allowed to truncate the bytes (see `save`).
104
+ */
105
+ private readonly unmovable;
106
+ /**
107
+ * @param root - directory holding one `<sessionId>.json` per session.
108
+ * @param options - the error seam (see {@link CommentStoreOptions}).
109
+ */
110
+ constructor(root: string, options?: CommentStoreOptions);
111
+ /**
112
+ * Load every session's comments. A malformed row is skipped rather than
113
+ * failing the load: one bad comment must not cost the whole workspace's
114
+ * comments (or the plugin's activation).
115
+ *
116
+ * A file that cannot be READ is skipped the same way, because the alternative is
117
+ * worse in both directions: the old all-or-nothing load threw, the caller logged
118
+ * once and carried on with an EMPTY store, and the next save then wrote that
119
+ * emptiness back over every good file. One unreadable file therefore cost every
120
+ * session's comments. Skipping keeps the other sessions' threads, and the skipped
121
+ * file is moved aside (`.corrupt`) rather than dropped: it is not deleted — the
122
+ * bytes may be the only copy — but it is also not left where the next save for
123
+ * that session would silently overwrite it, which would destroy them for good.
124
+ *
125
+ * When even that fails — the file cannot be moved aside — that session's writes are
126
+ * refused from then on (see `save`) rather than allowed to truncate the only copy.
127
+ * "Skipped but still writable" is exactly the shape that turns a read failure into
128
+ * data loss: the store would answer "added", write the memory-only view over bytes
129
+ * nobody could read, and lose what the skip had just refused to guess at.
130
+ * @returns how many comments were loaded.
131
+ */
132
+ loadAll(): Promise<number>;
133
+ /**
134
+ * The files a load skipped, so the caller can say what went missing instead of
135
+ * reporting a session whose comments silently vanished.
136
+ * @returns the skipped files' paths, in load order.
137
+ */
138
+ skippedFiles(): string[];
139
+ /**
140
+ * The failure the reader has to be told about, or `undefined` while every write works.
141
+ * This is what rides the list read so the panel can say the threads are memory-only;
142
+ * a later successful save retracts it, mirroring the pending half's own report.
143
+ *
144
+ * Per SESSION, not one global slot: a deployment where one session's file cannot be
145
+ * written and another's can is the ordinary case (a locked file, a full quota, a
146
+ * directory that vanished), and letting the healthy write retract the broken one's
147
+ * report would say the threads are safe while that session's live in memory only —
148
+ * the same "a failure that looks like a success" trap, one session over. A session's
149
+ * own success is what retracts its own entry (see `reportPersist`).
150
+ * @returns the most recent outstanding failure's message, or undefined.
151
+ */
152
+ persistError(): string | undefined;
153
+ /**
154
+ * Record a skipped file: keep it for the caller, name it, and move it aside so a later
155
+ * save for that session writes a fresh file rather than overwriting the unreadable
156
+ * bytes. A quarantine that itself fails is still a skip — the store must not be the
157
+ * reason a single bad file takes the whole load down — but that session's file is then
158
+ * the only copy of its threads, so its writes are refused (see `save`) instead of being
159
+ * allowed to truncate it.
160
+ * @param file - the file that could not be read.
161
+ * @param error - why it could not be read.
162
+ */
163
+ private skip;
164
+ /**
165
+ * The comments one SCOPE may read, oldest first (the order threads are read in).
166
+ *
167
+ * The scope is the caller's lineage rule (`CommentScope`), so a seat reads every thread of the seats
168
+ * it shares a lineage root with — not only the ones it wrote. The files do not move for that: each
169
+ * comment still lives in its author's file (see `save`), and the fan-out happens here, in memory,
170
+ * over the one map `loadAll` filled from every file. That is what keeps a teammate's already-written
171
+ * threads visible, with no migration.
172
+ * @param scope - which comment authors this caller may read.
173
+ * @returns the comments those authors wrote, oldest first.
174
+ */
175
+ list(scope: CommentScope): CommentRecord[];
176
+ /** One author's own comments, oldest first: the WRITE-SET of that author's file (see `save`). */
177
+ private authoredBy;
178
+ /** A comment by id, whatever session it belongs to. */
179
+ get(id: string): CommentRecord | undefined;
180
+ /**
181
+ * A counter bumped on every change, so a poller can tell "nothing new" from
182
+ * "re-read": the list endpoint ships the session's comments anyway, and this is
183
+ * what says whether anything moved since the last read.
184
+ */
185
+ commentsRevision(): number;
186
+ /**
187
+ * Fold what ONE transcript now says into the threads that asked there, so a thread whose answer has
188
+ * arrived (or been rewritten) wears the dot.
189
+ *
190
+ * The answers are DERIVED on every list read and never trusted from the file (see
191
+ * `CommentRecord.answerNow`): the read is the only moment the host knows what the agent has said, and
192
+ * the comparison it makes is against a baseline that survives a restart — a restart must not light a
193
+ * dot the reader already cleared, and a read that changed nothing must not light one at all. That is
194
+ * what `answerSeen` is, and why this writes a baseline only when the text actually moved.
195
+ *
196
+ * A question the transcript shows no answer for goes back to ABSENT rather than looking like an
197
+ * empty answer: a log this process cannot read (or a turn that has not written yet) then leaves the
198
+ * dot exactly as it was, instead of raising it on every poll.
199
+ *
200
+ * The patch's own key is the TRANSCRIPT, not the comment's author: a thread written in one seat can
201
+ * hold a question asked from another (see `askTranscript`), and this read is that other seat's log.
202
+ * Only the questions whose answer lives in `sessionId` are folded; a thread that also holds questions
203
+ * asked elsewhere keeps those exactly as that transcript's own fold left them.
204
+ *
205
+ * @param sessionId - the transcript being folded, which owns the questions it is handed.
206
+ * @param answers - the answer text per question id, as that transcript read it.
207
+ * @returns whether any thread changed.
208
+ */
209
+ syncAnswers(sessionId: SessionId, answers: Readonly<Record<string, AskRead>>): boolean;
210
+ /**
211
+ * Mark one comment as looked at: the dot goes out, and the answers it currently shows become the
212
+ * baseline a later rewrite is measured against — otherwise the next read would find the same answer
213
+ * "new" again and put the dot straight back up.
214
+ *
215
+ * Deliberately its own seam rather than something a read does: only a reader action (the card coming
216
+ * into view) may clear attention, and a read that cleared it would make the dot impossible to see.
217
+ *
218
+ * @param id - the comment the reader has in front of them.
219
+ * @returns whether the comment existed.
220
+ */
221
+ markSeen(id: string): boolean;
222
+ /**
223
+ * Record one annotation.
224
+ *
225
+ * Writing a comment that is already here is the SAME comment written again — a client
226
+ * retrying a request whose response was dropped, or a second client sending the id it
227
+ * was handed — so the questions already asked in it are kept and only the annotation's
228
+ * own fields move. Overwriting the whole record would erase the identity of a question
229
+ * that is in flight, and its answer could then never be matched back to this thread;
230
+ * `createdAt` is kept for the same reason, since it is what a list read orders by.
231
+ *
232
+ * @param record - the comment to store.
233
+ * @returns the stored comment, which is the existing one when the id was already here.
234
+ */
235
+ add(record: CommentRecord): CommentRecord;
236
+ /**
237
+ * Record several annotations at once, as ONE write per affected session.
238
+ *
239
+ * This is what putting a batch of restored comments back needs and what a loop of `add` calls
240
+ * cannot give it: each `add` saves its session's file, so an undo that restored ten threads wrote
241
+ * that file ten times — ten chances for a transient failure to leave the store and the disk
242
+ * disagreeing about an action the reader already watched happen. The records are stored by the SAME
243
+ * rule as `add` (a new id verbatim, an existing one merged with its `createdAt` and its questions
244
+ * kept), which is what lets a restore be an ordinary write rather than a special case: a snapshot
245
+ * that came back as a new comment, with a fresh id and no questions, would be a look-alike of the
246
+ * reader's thread.
247
+ *
248
+ * A batch that carries nothing changes nothing, so a no-op restore does not rewrite a file.
249
+ *
250
+ * @param records - the comments to store, in the caller's order.
251
+ * @returns the stored record for each input, index-aligned: what landed, which is not always what
252
+ * was handed over (a merge keeps the fields the existing comment owns). A count would drop that,
253
+ * and a caller needing it would have to `get` each id back and race a later change.
254
+ */
255
+ addMany(records: readonly CommentRecord[]): CommentRecord[];
256
+ /**
257
+ * The record as the store keeps it: a new id VERBATIM, an id already here merged.
258
+ *
259
+ * The one place the merge rule lives, so `add` and `addMany` cannot drift: writing a comment that
260
+ * is already here is the same comment written again — a client retrying a request whose response
261
+ * was dropped, or a second client sending the id it was handed — so the questions already asked in
262
+ * it are kept and only the annotation's own fields move. Overwriting the whole record would erase
263
+ * the identity of a question that is in flight, and its answer could then never be matched back to
264
+ * this thread; `createdAt` is kept for the same reason, since it is what a list read orders by.
265
+ * @param record - the comment as the caller wrote it.
266
+ * @returns the record to store under its id.
267
+ */
268
+ private mergeOf;
269
+ /**
270
+ * Drop one comment the caller's scope covers.
271
+ * @param scope - which comment authors the caller may act on.
272
+ * @param id - the comment to drop.
273
+ * @returns whether it was there (and was in scope).
274
+ */
275
+ remove(scope: CommentScope, id: string): boolean;
276
+ /**
277
+ * Drop several comments in one write PER FILE that holds them.
278
+ *
279
+ * This is what a batch action needs and what a loop of `remove` calls cannot give it: each
280
+ * `remove` saves the file, so ending ten comments wrote that file ten times — ten chances for a
281
+ * transient failure to leave the store and the disk disagreeing about an action the reader asked
282
+ * for once. The save happens only when something was actually dropped, so a batch that matches
283
+ * nothing does not rewrite a file.
284
+ *
285
+ * Each record is removed from the file that HOLDS it, which is its author's (`save`), not the
286
+ * caller's: once a lineage reads every seat's threads, a batch can name comments written by several
287
+ * seats, and routing them all to the caller's file would leave the real files holding records the
288
+ * store no longer has — the next load would resurrect every one of them.
289
+ *
290
+ * An id outside the scope, or one that is not here at all, is skipped rather than refused:
291
+ * a batch is one request and the comments it names are the ones it can act on, and a comment
292
+ * another client already ended is the state the caller was asking for anyway.
293
+ *
294
+ * @param scope - which comment authors the caller may act on.
295
+ * @param ids - the comments to drop; a repeated id is one comment.
296
+ * @returns the ids that were dropped, in the order given.
297
+ */
298
+ removeMany(scope: CommentScope, ids: readonly string[]): string[];
299
+ /**
300
+ * Every comment one pending entry carries, oldest first.
301
+ *
302
+ * The read half of `removeForEntry` and kept beside it, because they are two answers about the same
303
+ * fact: which comments an entry owns. A caller that is about to drop the entry asks this first, so
304
+ * the undo pair can carry them back; the two must agree on that set, or a restore would put back
305
+ * fewer (or more) threads than the drop took.
306
+ * @param entryId - the entry id (= path).
307
+ * @returns that entry's comments, in list order.
308
+ */
309
+ forEntry(entryId: string): CommentRecord[];
310
+ /**
311
+ * Drop every comment of one pending entry. This is what "a comment dies with
312
+ * its entry" means, and the entry knows nothing about comments, so the caller
313
+ * runs it beside the entry's own removal.
314
+ * @param entryId - the entry id (= path) that left the list.
315
+ * @returns how many comments went with it.
316
+ */
317
+ removeForEntry(entryId: string): number;
318
+ /**
319
+ * The orphan sweep: prune every comment whose entry is not in `entryIds`, in MEMORY ONLY.
320
+ *
321
+ * The explicit `removeForEntry` is the primary path and the ONLY one that erases; this is the view
322
+ * filter that guarantees no read is ever handed a comment naming a file the list does not carry. Run it
323
+ * when the store loads and on every list read.
324
+ *
325
+ * WHY IT DOES NOT SAVE (2026-10-06, a data loss measured on the live host). The sweep used to write the
326
+ * pruned state to the files, on the assumption that an entry absent here had been removed while the host
327
+ * was running. That is only one of two ways an entry can be missing; the other is that its ADD never
328
+ * reached `pending.json` — a pending entry is folded in memory and persisted on a later flush — and then
329
+ * the sweep deleted the comments hanging on it AND saved the deletion. Three real annotations were gone
330
+ * from the disk after a restart, not merely hidden: the reader's cards were written first and their
331
+ * entries never landed. An entry that exists but is not on disk yet is not an orphan.
332
+ *
333
+ * A write would not even help the case it was added for. A second client must never be handed a comment
334
+ * naming a file the same read no longer lists — that is a VIEW requirement, and the prune satisfies it.
335
+ * Erasure belongs to the paths where a removal was actually ASKED FOR: `removeForEntry` (what the panel's
336
+ * own drop calls, and it removes the comments in the same tick), and `remove`/`removeMany` for one
337
+ * thread. A genuine orphan therefore stays on the disk, hidden, until the next boot that sees the entry
338
+ * again — a row nobody reads is a smaller price than a reader's annotation nobody can recover.
339
+ *
340
+ * @param entryIds - every entry id the pending store currently holds.
341
+ * @returns how many comments the view dropped.
342
+ */
343
+ retain(entryIds: ReadonlySet<string>): number;
344
+ /**
345
+ * Record one question asked in a thread. The answer itself is never stored, and the
346
+ * request id is minted by the caller before it submits, so this runs as part of the
347
+ * ask rather than waiting for the session to answer. A thread keeps its questions in
348
+ * order: the first is the annotation's own, and each follow-up appends.
349
+ *
350
+ * The reader's own words are stored WITH the question (`text`), because they exist
351
+ * nowhere else: the transcript holds the prompt, which is those words wrapped in the
352
+ * marker, the reference and the rules, and a thread that showed the prompt would show
353
+ * the scaffolding rather than the question.
354
+ *
355
+ * The ASKING session is recorded on the question, not taken from the comment: a thread another
356
+ * seat wrote can be asked from this one, and the prompt then goes into the seat the human is
357
+ * using, so that session's transcript is where the answer will appear (see `askTranscript`).
358
+ *
359
+ * Matched by comment ID alone: the request id is minted here and is globally unique, and the
360
+ * caller has already checked that the thread is in its scope.
361
+ *
362
+ * @param sessionId - the transcript the question was submitted into.
363
+ * @param id - the comment that was asked in.
364
+ * @param requestId - the identity the submission carries into the transcript.
365
+ * @param turn - the turn that claimed it, when that is already known.
366
+ * @param text - the reader's own words, without the prompt's wrapper.
367
+ * @returns whether the comment existed.
368
+ */
369
+ recordAsk(sessionId: SessionId, id: string, requestId: string, turn: number | undefined, text: string): boolean;
370
+ /**
371
+ * Record the turn that claimed one question, as the agent's inbox reported it. Two
372
+ * questions claimed by the same turn were answered together, which is a fact the
373
+ * panel can state instead of guessing from transcript positions.
374
+ *
375
+ * Matched by REQUEST ID alone: it is minted per submission and globally unique, so it names the
376
+ * question on its own — and a thread another seat wrote can hold a question asked from this seat,
377
+ * which the old `comment.sessionId === sessionId` guard would refuse to write down.
378
+ * @param requestId - the request identity the inbox reported back.
379
+ * @param turn - the turn that claimed it.
380
+ * @returns how many threads the patch touched.
381
+ */
382
+ recordTurnForRequest(requestId: string, turn: number): number;
383
+ /**
384
+ * Record that a session dropped one question before a turn claimed it: nothing is
385
+ * coming, and the reader is told rather than left waiting.
386
+ *
387
+ * Matched by REQUEST ID alone, for the same reason as `recordTurnForRequest`.
388
+ * @param requestId - the request identity the inbox reported back.
389
+ * @returns how many threads the patch touched.
390
+ */
391
+ markDroppedForRequest(requestId: string): number;
392
+ /**
393
+ * Record that a turn is over, so the questions it claimed stop looking pending.
394
+ *
395
+ * The turn number is the only handle the stopping event carries — no message, no
396
+ * request id — so the questions that were claimed by that turn are the ones patched
397
+ * (see `recordTurnForRequest`, which wrote the number down when the inbox claimed
398
+ * them). A question whose turn is over and that the transcript shows no answer for
399
+ * was cut off; the client says so instead of waiting forever. Nothing here decides
400
+ * that: whether an answer exists is read from the transcript on each list read, so a
401
+ * turn that stopped AFTER writing its answer keeps showing the answer.
402
+ *
403
+ * Matched by TURN **within one transcript** (`askTranscript`), never by the thread's author: turn
404
+ * numbers are only unique inside a session, so two sessions can both have a turn 7, and a thread that
405
+ * holds questions from both would otherwise have one session's ending mark the other's questions
406
+ * over — the reader would be told a question was cut off while its answer was still coming.
407
+ * @param sessionId - the transcript whose turn ended.
408
+ * @param turn - the turn that stopped.
409
+ * @returns how many threads the patch touched.
410
+ */
411
+ markTurnEnded(sessionId: SessionId, turn: number): number;
412
+ /**
413
+ * Apply one patch to every comment it matches, and schedule the affected sessions'
414
+ * writes. The one mutation seam, so no caller has to remember to bump the revision
415
+ * or to save: a patch that matches nothing changes nothing.
416
+ * @param match - which comments to patch.
417
+ * @param patch - the change to apply.
418
+ * @returns how many comments changed.
419
+ */
420
+ private patch;
421
+ /**
422
+ * Wait for every scheduled write to settle.
423
+ *
424
+ * The mutators return as soon as a change is in memory — that is what lets an
425
+ * entry's removal take its comments with it in the same tick — so this is how a
426
+ * caller that must not lose the write (a dispose path, a test asserting the file)
427
+ * waits for the disk.
428
+ * @returns resolution after every session file's write chain has drained.
429
+ */
430
+ settled(): Promise<void>;
431
+ /**
432
+ * Write one session's comments, serialized against that file's own writes.
433
+ *
434
+ * A rejected write is reported, not swallowed: the mutation already answered
435
+ * "added" and the panel is drawing the thread from memory, so a silent failure means
436
+ * the reader keeps writing into a thread that a restart will erase. The report rides
437
+ * the list read (`persistError`) and the caller's logger, and a later write that works
438
+ * retracts it the way the pending half retracts its own.
439
+ *
440
+ * A session whose file could not be read at load AND could not be moved aside is not
441
+ * written at all: the only copy of that session's threads is the bytes the load could
442
+ * not parse, and the store's memory holds just what has been added since. Writing the
443
+ * memory-only view would TRUNCATE that copy — turning a read failure into a permanent
444
+ * loss — so the save is refused and reported instead, which is what tells the reader
445
+ * those comments are memory-only.
446
+ *
447
+ * The write-set is the AUTHOR's own comments (`authoredBy`), never the wider read scope
448
+ * (`list`): a read spans the lineage, but a file must hold exactly the comments whose
449
+ * author it is named for — writing the scope's comments into one seat's file would
450
+ * duplicate every other seat's threads on disk and, on the next load, in memory.
451
+ * @param sessionId - the author whose file to rewrite.
452
+ */
453
+ private save;
454
+ /**
455
+ * Publish the write state of one session's file, once per distinct report. Kept as its own
456
+ * step so both the success and the failure of the chain above land in one place — the
457
+ * retraction is what arms a later failure to be reported again.
458
+ *
459
+ * Keyed per file, so a healthy session's write cannot retract a broken one's report (see
460
+ * `persistError`); the callback sees the aggregate, which is what rides the list read.
461
+ * @param file - the session file the write was for.
462
+ * @param message - the failure's text, or undefined when the write worked.
463
+ */
464
+ private reportPersist;
465
+ }
@@ -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, DiffApprovalListCountValue, DiffApprovalListValue, DiffApprovalOpenAction, DiffApprovalOpenValue, DiffApprovalRefreshOutcome, DiffApprovalRefreshValue, CommentAnchor, CommentQuoteLine, CommentRecord, DiffApprovalCommentAddValue, DiffApprovalCommentRemoveManyValue, DiffApprovalCommentRemoveValue, LineageDirection, PendingEntry, PendingEntryKind, PendingFileDiff, SessionLineage, } 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. */