dsh-diff-approval 0.30.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.
@@ -4,7 +4,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
4
4
  import type { DockSnapshot } from './dock.tsx';
5
5
  import type { CommentDraft } from './port.ts';
6
6
  import type { CommentLineRange } from '../comment-lines.ts';
7
- import type { CommentRecord, DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalCommentAddValue, DiffApprovalCommentAskValue, DiffApprovalCommentRemoveManyValue, DiffApprovalCommentRemoveValue, DiffApprovalOpenAction, DiffApprovalRefreshValue, PendingFileDiff, VcsImportValue } from '../types.ts';
7
+ import type { CommentRecord, DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalCommentAddValue, DiffApprovalCommentAskValue, DiffApprovalCommentRemoveManyValue, DiffApprovalCommentRemoveValue, DiffApprovalOpenAction, DiffApprovalRefreshValue, DiffApprovalUpdateValue, PendingFileDiff, VcsImportValue } from '../types.ts';
8
8
  /** What the panel reads and drives: the pending list plus in-flight entries. */
9
9
  export interface PendingDiffSnapshot {
10
10
  /** Whether a list read has completed at least once. */
@@ -32,6 +32,21 @@ export interface PendingDiffSnapshot {
32
32
  commentAnswers: Record<string, string>;
33
33
  /** A read failure's message; absent while the latest read succeeded. */
34
34
  error?: string;
35
+ /**
36
+ * How many rows the host says this session has, from the LIGHT read (`list-count`) — the badge's
37
+ * question, answered without shipping any file content.
38
+ *
39
+ * It is the number a full read would have carried, but it is a separate fact with a separate age: a
40
+ * count lands on ticks that ask only for a count (when nothing is showing), and a FULL read clears it,
41
+ * because that read carries the same fact in `files` and is newer. So a reader's rule is
42
+ * `count ?? files.length` — the fallback covers both "no count has arrived" and "the list is fresher",
43
+ * and a count that FAILED leaves the previous number in place rather than showing zero.
44
+ *
45
+ * Absent on a host that predates the endpoint: there the store keeps doing full reads, the value stays
46
+ * absent, and every reader stays on the fallback — today's behaviour, at today's cost (see
47
+ * `PendingDiffStore.refreshCount`).
48
+ */
49
+ count?: number | undefined;
35
50
  /** Entry ids whose keep/revert is in flight; their controls are disabled. */
36
51
  busy: ReadonlySet<string>;
37
52
  /** Entry ids whose last keep/revert failed, mapped to the error message; the panel surfaces these inline (row tag + detail banner) instead of hiding the list. */
@@ -69,8 +84,81 @@ export interface PendingDiffSnapshot {
69
84
  * panel is closed) via a bottom-right notice. */
70
85
  redoCleared?: boolean;
71
86
  }
87
+ /** One filter over one session's view of the pending list; the shape every view hook takes. */
88
+ export type PendingViewSelector<T> = (view: PendingDiffSnapshot) => T;
89
+ /**
90
+ * The number a badge, a header entry or a dock chip shows for one session: the light count when one has
91
+ * arrived, else the length of the list that read carried.
92
+ *
93
+ * The fallback is not a nicety — it is the whole older-host story and the state before the first count
94
+ * lands. `count` is absent when no count has arrived (the page just loaded, or the host predates the
95
+ * endpoint, where the store keeps doing full reads) and it is cleared by every full read, whose `files`
96
+ * are the same fact and newer. So `count` is only ever shown while it is the FRESHEST number the client
97
+ * has; otherwise the list speaks. See `PendingDiffSnapshot.count`.
98
+ * @param view - one session's view.
99
+ * @returns how many rows it holds.
100
+ */
101
+ export declare function countOf(view: PendingDiffSnapshot): number;
102
+ /**
103
+ * The view a seat draws when it has NO reviewable session: a session that is absent, or one the shell has
104
+ * told us is blank — a brand-new session with nothing said in it yet.
105
+ *
106
+ * This exists because the page-wide fallback is the wrong answer for such a seat. `viewFor(undefined)`
107
+ * and `getSnapshot()` deliberately answer the newest session read on the page (the whole-page contract the
108
+ * remap follows), and with the store's slots keyed only by session id and no workspace comparison anywhere
109
+ * in this client, "the newest read" can be another session's list — even another workspace's. A seat that
110
+ * has nothing of its own to show must draw NOTHING, and this is the nothing: no rows (so `countOf` is 0),
111
+ * no comments, no workspace, and `read: false` because nothing has been read FOR this seat.
112
+ *
113
+ * Shared rather than re-spelled per seat: the panel's badge and body, the header entry's count and the
114
+ * dock chip all have to agree that "no reviewable session" means zero, and a second literal here is how
115
+ * they would drift. Frozen because it is handed to readers as a snapshot: nothing may write through it.
116
+ */
117
+ export declare const EMPTY_PENDING_VIEW: PendingDiffSnapshot;
118
+ /**
119
+ * The view a seat DRAWS when there is nothing to review: the same emptiness as {@link EMPTY_PENDING_VIEW},
120
+ * but READ.
121
+ *
122
+ * The difference is what the panel draws: an unread view is a session whose list is still coming, so it
123
+ * shows the loading state, while this one says "no pending changes" — the truth for a session that is
124
+ * absent or blank. Using the unread view here would leave a docked panel spinning forever behind an entry
125
+ * that is disabled and can never be opened.
126
+ */
127
+ export declare const NOTHING_TO_REVIEW_VIEW: PendingDiffSnapshot;
128
+ /** Function shape of {@link PendingViewHooks.pendingView}; named so a seat can call it directly. */
129
+ export type PendingViewReader = <T>(sessionId: SessionId | undefined, select: PendingViewSelector<T>) => T;
130
+ /**
131
+ * How a seat reads pending state for the session it is actually about.
132
+ *
133
+ * The page runs several seats at once — the sidebar footer's badge, the Session header's entry, the
134
+ * docked tab's chip and body — and they can belong to different sessions. Reading the ONE page-wide
135
+ * snapshot is what let a badge show another session's count, so a seat that knows its session reads
136
+ * that session's own view through {@link PendingViewHooks.pendingView} and nothing else.
137
+ *
138
+ * This lives BESIDE the face's `hooks`, not inside it: that compartment is a map of observables
139
+ * (`HooksSources`), and the framework binds each one onto the component props as a `use<Name>` selector
140
+ * hook. Folding a non-observable in there would stop the whole compartment from matching, and the panel
141
+ * would lose `usePending` and `useDock` with it. `pendingView` is a read, not an observable: the panel's
142
+ * `usePending` subscription is what re-renders it.
143
+ *
144
+ * The hook is optional because the shapes are read structurally: a host (or a test) that offers only
145
+ * `pending` still renders, with the panel falling back to the page-wide snapshot.
146
+ */
147
+ export interface PendingViewHooks {
148
+ /**
149
+ * Read one session's own view, with `select` applied inside the subscription.
150
+ *
151
+ * `sessionId === undefined` means "I have no session of my own": the answer is then the page-wide
152
+ * view (the newest session read), which is what a whole-page caller wants. A session that has never
153
+ * been read answers an unread empty view — never another session's list.
154
+ * @param sessionId - the session whose view to read.
155
+ * @param select - what to take out of that view.
156
+ * @returns the selected value.
157
+ */
158
+ pendingView?: PendingViewReader | undefined;
159
+ }
72
160
  /** The injected face the panel component receives from the plugin body. */
73
- export interface PendingPanelFace {
161
+ export interface PendingPanelFace extends PendingViewHooks {
74
162
  hooks: {
75
163
  /** Live pending-diff snapshot for the current page. */
76
164
  pending: HostObservable<PendingDiffSnapshot>;
@@ -91,8 +179,26 @@ export interface PendingPanelFace {
91
179
  /** Close the docked tab now, when one exists (the quick-summon chord's job
92
180
  * while the panel lives in the sidebar). */
93
181
  closeDock?: (() => void) | undefined;
182
+ /**
183
+ * Ask the host whether a newer release of this plugin is published.
184
+ *
185
+ * Optional, and deliberately so: a seat that is not given one (an older shell wiring, or a
186
+ * harness that does not stage it) simply draws no update chip. The panel calls it ONCE, after
187
+ * mount, and never waits on it — a host that cannot answer, or has no answer to give, leaves the
188
+ * chip's site exactly as it was. The answer is drawn in ONE place (the open file's status bar, at
189
+ * the panel's bottom edge) out of one piece of panel state.
190
+ */
191
+ onCheckUpdate?: (() => Promise<DiffApprovalUpdateValue>) | undefined;
94
192
  /** Read the pending list for the current session into the snapshot. */
95
193
  onRefresh: (sessionId: SessionId | undefined) => void;
194
+ /**
195
+ * The badge's tick, for a seat with nothing on screen: ask how many rows the session has, with no file
196
+ * content on the wire. Optional so a face built against an older shape still renders — the panel then
197
+ * keeps the full read, which is what such a page did before the verb existed.
198
+ */
199
+ onRefreshCount?: ((sessionId: SessionId | undefined) => void) | undefined;
200
+ /** The reader is looking at this file now: its row's unseen dot goes out. */
201
+ onMarkSeen?: ((sessionId: SessionId | undefined, id: string) => void) | undefined;
96
202
  /** Keep one operation. `keepListed` leaves the resolved entry in the list. */
97
203
  onKeep: (sessionId: SessionId, id: string, keepListed?: boolean) => Promise<void>;
98
204
  /** Revert one operation (restore its prior content, or remove a created file).
@@ -149,7 +255,7 @@ export interface PendingPanelFace {
149
255
  /**
150
256
  * Drop several annotations in one host call. Ending a pick of comments is one action the reader
151
257
  * asked for, so it goes to the host as one request and comes back as one read — see
152
- * `DiffApprovalStore.commentRemoveMany`.
258
+ * `PendingDiffStore.commentRemoveMany`.
153
259
  */
154
260
  onCommentRemoveMany: (sessionId: SessionId, ids: readonly string[]) => Promise<DiffApprovalCommentRemoveManyValue>;
155
261
  /**
@@ -159,6 +265,12 @@ export interface PendingPanelFace {
159
265
  * question so the thread can draw what was written.
160
266
  */
161
267
  onCommentAsk: (sessionId: SessionId, id: string, prompt: string, text: string) => Promise<DiffApprovalCommentAskValue>;
268
+ /**
269
+ * The reader has this comment's card in view: its unseen dot goes out, and the answers on it become
270
+ * the baseline a later rewrite is measured against. A reader action, so it is its own call rather
271
+ * than something a read does — a read that cleared attention would make the dot impossible to see.
272
+ */
273
+ onCommentSeen?: ((sessionId: SessionId, id: string) => void) | undefined;
162
274
  /** Acknowledge the redo-cleared notice so it is only surfaced once. */
163
275
  onAckRedoCleared: () => void;
164
276
  /** Acknowledge a refused-undo notice once the panel has said it (see `undoNotice`). */
@@ -12,8 +12,51 @@ import type { PendingDiffSnapshot } from './slots.ts';
12
12
  import type { CommentDraft, DiffApprovalPort } from './port.ts';
13
13
  /** The observable the panel reads and the plugin body drives. */
14
14
  export interface PendingDiffStore extends HostObservable<PendingDiffSnapshot> {
15
+ /**
16
+ * One session's own view of the pending list.
17
+ *
18
+ * The page runs several mounts of the panel at once — the footer entry, the Session header entry and
19
+ * the docked tab — and they can be about DIFFERENT sessions (the reader is in one, while the docked
20
+ * tab was opened from another). One page-wide snapshot made every badge read whichever session's read
21
+ * landed last, which is the cross-talk the reader reported: a badge showing another session's count,
22
+ * and a session with a real list showing zero. So each session's read publishes only into its own
23
+ * slot, and every consumer takes its own slot, by the session it is actually about.
24
+ *
25
+ * A session that has never been read answers an unread empty view — never another session's list.
26
+ * `sessionId === undefined` answers the most recently published view — exactly what
27
+ * {@link PendingDiffStore.getSnapshot} answers — because that is what a caller with no session of its
28
+ * own (a whole-page concern) is asking for.
29
+ * @param sessionId - the session whose view to read.
30
+ * @returns that session's snapshot, identity-stable until it is next published.
31
+ */
32
+ viewFor: (sessionId: SessionId | undefined) => PendingDiffSnapshot;
33
+ /**
34
+ * Every session's view this page is HOLDING, one per session it has read.
35
+ *
36
+ * This is the reader a press needs (see `produced-diff.ts`): a shell press names a file and asks whether
37
+ * ANY session this page has read holds it, and the press cannot wait for a poll. The slots are where the
38
+ * fresh paths live — the light read's `paths` and the last full read's `files` — so answering from the
39
+ * slots is what keeps a file edited since the last full read from being a press nothing recognises.
40
+ * @returns the held views; a session never read contributes nothing (it has no slot).
41
+ */
42
+ views: () => readonly PendingDiffSnapshot[];
15
43
  /** Re-read one session's pending list (an absent session empties the view). */
16
44
  refresh: (sessionId: SessionId | undefined) => Promise<void>;
45
+ /**
46
+ * Ask how many rows this session has, THE LIGHT WAY: one number, no file content.
47
+ *
48
+ * This is what a seat polls while it is showing nothing but its badge. It publishes into the same
49
+ * per-session slot as `refresh` — as `count`, never as `files` — so a badge keeps reading the view it
50
+ * already subscribes to, and it deliberately does NOT set `read` (a count is not a list: a session
51
+ * whose list has never been read stays unread, and its list stays empty rather than looking loaded).
52
+ *
53
+ * Failure keeps the LAST GOOD number: a count that failed knows nothing, and showing 0 or a blank
54
+ * badge for a list that may well be full is worse than showing the previous figure. A host that
55
+ * predates the endpoint (`unknown endpoint "list-count"`) is detected here ONCE and then this call
56
+ * degrades to `refresh`, which is exactly the old behaviour at exactly the old cost — one full read
57
+ * per tick — and an absent session has no count to ask for, so it returns without a call.
58
+ */
59
+ refreshCount: (sessionId: SessionId | undefined) => Promise<void>;
17
60
  /** Keep one operation. `keepListed` leaves the resolved entry in the list. */
18
61
  keep: (sessionId: SessionId, id: string, keepListed?: boolean) => Promise<void>;
19
62
  /** Revert one operation. `keepListed` leaves the resolved entry in the list. */
@@ -35,6 +78,11 @@ export interface PendingDiffStore extends HostObservable<PendingDiffSnapshot> {
35
78
  browse: (sessionId: SessionId, path?: string) => Promise<DiffApprovalBrowseValue>;
36
79
  /** Add one named path to the list, then refresh; resolves to what the scan did. */
37
80
  addPath: (sessionId: SessionId, path: string, includeUnchanged: boolean, exact?: boolean) => Promise<DiffApprovalAddValue>;
81
+ /**
82
+ * The reader is looking at this file now: its row's unseen dot goes out. Deliberately NOT followed by a
83
+ * re-read — see the note at the implementation.
84
+ */
85
+ markSeen: (sessionId: SessionId, id: string) => Promise<void>;
38
86
  /** Open one file with its default application or reveal it in the folder. */
39
87
  open: (sessionId: SessionId, id: string, action: DiffApprovalOpenAction) => Promise<void>;
40
88
  /** Keep every pending entry of one session in a single host call, then refresh. */
@@ -70,6 +118,8 @@ export interface PendingDiffStore extends HostObservable<PendingDiffSnapshot> {
70
118
  commentRemoveMany: (sessionId: SessionId, ids: readonly string[]) => Promise<DiffApprovalCommentRemoveManyValue>;
71
119
  /** Ask one stored comment as its own turn, then refresh (the answer is derived on read). */
72
120
  commentAsk: (sessionId: SessionId, id: string, prompt: string, text: string) => Promise<DiffApprovalCommentAskValue>;
121
+ /** The reader is looking at a comment: its card's dot goes out, then the list is re-read. */
122
+ commentSeen: (sessionId: SessionId, id: string) => Promise<void>;
73
123
  /** Drop every local fact (used on connection reset). */
74
124
  reset: () => void;
75
125
  /** Acknowledge a redo-cleared notice so the panel surfaces it only once. */
@@ -19,8 +19,8 @@
19
19
  */
20
20
  import type { Context } from '@deepseek-ai/cordis';
21
21
  import type { SessionId } from '@deepseek-ai/dsh-session';
22
- import type { DiffApprovalCommentAskValue } from './types.ts';
23
- import type { CommentStore } from './comments.ts';
22
+ import type { DiffApprovalCommentAskValue, CommentRecord } from './types.ts';
23
+ import type { CommentScope, CommentStore } from './comments.ts';
24
24
  /** One derived transcript message, narrowed to what answering a comment needs. */
25
25
  interface DerivedMessage {
26
26
  role?: unknown;
@@ -75,6 +75,14 @@ export interface AskRead {
75
75
  /** The claiming turn is over (`turn/end` in the log), whatever it did or did not write. */
76
76
  ended: boolean;
77
77
  }
78
+ /**
79
+ * One request's already-read transcripts, keyed by the session read.
80
+ *
81
+ * `undefined` is cached as a value (hence `has`, not a truthy check): a session whose log cannot be
82
+ * read must not be asked for again within the same read, or a folded-over lineage would pay for the
83
+ * same failure once per transcript.
84
+ */
85
+ export type TranscriptCache = Map<SessionId, readonly LogEvent[] | undefined>;
78
86
  /**
79
87
  * The answer to one submitted comment, read from the session's own event log.
80
88
  *
@@ -99,13 +107,6 @@ export interface AskRead {
99
107
  * @returns the claiming turn, its answer text, and whether the turn is over.
100
108
  */
101
109
  export declare function answerForRequest(events: readonly LogEvent[], requestId: string): AskRead;
102
- /**
103
- * Read every question one session has asked, keyed by its request id.
104
- * @param events - the session's event log, in sequence order.
105
- * @param requestIds - the questions' request ids.
106
- * @returns the read per request id; a question the log holds nothing for reads as nothing.
107
- */
108
- export declare function answersIn(events: readonly LogEvent[], requestIds: readonly string[]): Record<string, AskRead>;
109
110
  /**
110
111
  * Submits comments as turns of their session, and reads their answers back.
111
112
  *
@@ -140,24 +141,44 @@ export declare class CommentAsker {
140
141
  * arrives while the submission is still in flight already finds its comment. A
141
142
  * submission that rejects is marked dropped rather than left looking pending.
142
143
  *
143
- * @param sessionId - the session the comment belongs to.
144
+ * @param sessionId - the session the question is asked FROM: the prompt goes into it, so its
145
+ * transcript is where the answer will appear.
144
146
  * @param commentId - the comment to ask.
145
147
  * @param prompt - the prompt text to send verbatim.
146
148
  * @param text - the reader's own words, which the prompt wraps: the thread keeps these.
147
149
  * @param signal - aborts the submission.
150
+ * @param inScope - which comment authors the caller's lineage covers, so a thread another seat
151
+ * wrote can be asked from this one.
148
152
  * @returns what the request did.
149
153
  */
150
- ask(sessionId: SessionId, commentId: string, prompt: string, text: string, signal: AbortSignal): Promise<DiffApprovalCommentAskValue>;
154
+ ask(sessionId: SessionId, commentId: string, prompt: string, text: string, signal: AbortSignal, inScope: CommentScope): Promise<DiffApprovalCommentAskValue>;
155
+ /**
156
+ * One scope's questions, grouped by THE TRANSCRIPT each was submitted into.
157
+ *
158
+ * A lineage's threads may hold questions asked from several seats, and an answer lives in the
159
+ * transcript of the session that was asked — so the read is per transcript, not per thread. Grouping
160
+ * here (rather than reading each thread separately) is what keeps a poll to ONE log walk per
161
+ * transcript, however many threads asked there.
162
+ *
163
+ * The legacy case is why `askTranscript` exists: a question asked before the asking session was
164
+ * recorded has only the comment's own session to go on, which is where it went.
165
+ * @param visible - the comments this read may see (already scoped by the caller).
166
+ * @returns the request ids per transcript; empty when nothing was ever asked.
167
+ */
168
+ answerGroups(visible: readonly CommentRecord[]): Map<SessionId, string[]>;
151
169
  /**
152
- * Every question of one session and what the transcript says about it, keyed by the
170
+ * Every question submitted into ONE transcript and what it says about them, keyed by the
153
171
  * question's request id (which is what `CommentAsk` records and what a client matches
154
- * against). Derived from the session's own event log on every call and never stored:
155
- * the log is the one source of truth for what the agent said, so a stored copy could
156
- * only ever disagree with it.
157
- * @param sessionId - the session whose comments to read.
172
+ * against). Derived from that session's event log on every call and never stored: the log
173
+ * is the one source of truth for what the agent said, so a stored copy could only ever
174
+ * disagree with it.
175
+ * @param sessionId - the transcript to read.
176
+ * @param requestIds - the questions that were submitted into it.
177
+ * @param cache - this request's already-read logs, so the answer fold and the turn-end read
178
+ * below share ONE walk of a log that can hold >100k events.
158
179
  * @returns the read per question; empty when the session holds no questions.
159
180
  */
160
- answers(sessionId: SessionId): Record<string, AskRead>;
181
+ answersFor(sessionId: SessionId, requestIds: readonly string[], cache?: TranscriptCache): Record<string, AskRead>;
161
182
  /**
162
183
  * The turns the session's log shows as over, for the questions it holds.
163
184
  *
@@ -167,10 +188,11 @@ export declare class CommentAsker {
167
188
  * a client that connected afterwards, a deployment whose agent events cannot be
168
189
  * subscribed at all — because `turn/end` is durable and the event is not.
169
190
  *
170
- * @param sessionId - the session whose turns to check.
191
+ * @param sessionId - the transcript whose turns to check.
192
+ * @param cache - this request's already-read logs (see `answersFor`).
171
193
  * @returns the turns the log closed, or empty when the log cannot be read.
172
194
  */
173
- endedTurns(sessionId: SessionId): number[];
195
+ endedTurns(sessionId: SessionId, cache?: TranscriptCache): number[];
174
196
  /**
175
197
  * One session's event log, as the reads above take it.
176
198
  *
@@ -180,7 +202,12 @@ export declare class CommentAsker {
180
202
  * and `snapshotEvents` hands back a frozen, sequence-ordered copy of it, so walking it
181
203
  * is a plain read that cannot disturb the session.
182
204
  *
205
+ * The cache is per REQUEST, not per session: one list read asks this twice for every transcript
206
+ * (the answer fold and the turn-end read), and the copy is the expensive part of a long session's
207
+ * log. `has` rather than a truthy check, so "cannot be read" is cached as such instead of being
208
+ * asked again.
183
209
  * @param sessionId - the session to read.
210
+ * @param cache - this request's already-read logs, when the caller is doing several reads.
184
211
  * @returns its events, or undefined when no live session can be read.
185
212
  */
186
213
  private sessionEvents;
@@ -200,7 +227,7 @@ export declare class CommentAsker {
200
227
  * released first, so re-arming is not a leak: one set of handlers per live agent,
201
228
  * not one more per identity change.
202
229
  *
203
- * Called on every ask AND on every poll read (`answers`), because the agent can be
230
+ * Called on every ask AND on every poll read (`answersFor`), because the agent can be
204
231
  * replaced while a question is still pending — the case where nothing asks again.
205
232
  * @param sessionId - the session whose agent to watch.
206
233
  */
@@ -16,7 +16,8 @@
16
16
  * @module dsh-diff-approval/src/comments
17
17
  */
18
18
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
19
- import type { CommentRecord } from './types.ts';
19
+ import type { CommentAsk, CommentRecord } from './types.ts';
20
+ import type { AskRead } from './comment-ask.ts';
20
21
  /**
21
22
  * The comment directory that belongs to a pending-storage root.
22
23
  *
@@ -32,6 +33,28 @@ import type { CommentRecord } from './types.ts';
32
33
  * @returns the directory holding the comments that belong to that root.
33
34
  */
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;
35
58
  /**
36
59
  * How a caller hears about the two ways a comment file can go wrong.
37
60
  *
@@ -138,8 +161,20 @@ export declare class CommentStore {
138
161
  * @param error - why it could not be read.
139
162
  */
140
163
  private skip;
141
- /** One session's comments, oldest first (the order threads are read in). */
142
- list(sessionId: SessionId): CommentRecord[];
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;
143
178
  /** A comment by id, whatever session it belongs to. */
144
179
  get(id: string): CommentRecord | undefined;
145
180
  /**
@@ -148,6 +183,42 @@ export declare class CommentStore {
148
183
  * what says whether anything moved since the last read.
149
184
  */
150
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;
151
222
  /**
152
223
  * Record one annotation.
153
224
  *
@@ -196,30 +267,35 @@ export declare class CommentStore {
196
267
  */
197
268
  private mergeOf;
198
269
  /**
199
- * Drop one comment.
200
- * @param sessionId - the session the caller believes the comment belongs to.
270
+ * Drop one comment the caller's scope covers.
271
+ * @param scope - which comment authors the caller may act on.
201
272
  * @param id - the comment to drop.
202
- * @returns whether it was there (and belonged to that session).
273
+ * @returns whether it was there (and was in scope).
203
274
  */
204
- remove(sessionId: SessionId, id: string): boolean;
275
+ remove(scope: CommentScope, id: string): boolean;
205
276
  /**
206
- * Drop several comments of one session in ONE write.
277
+ * Drop several comments in one write PER FILE that holds them.
207
278
  *
208
279
  * 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.
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.
213
289
  *
214
- * An id of another session, or one that is not here at all, is skipped rather than refused:
290
+ * An id outside the scope, or one that is not here at all, is skipped rather than refused:
215
291
  * a batch is one request and the comments it names are the ones it can act on, and a comment
216
292
  * another client already ended is the state the caller was asking for anyway.
217
293
  *
218
- * @param sessionId - the session the caller believes the comments belong to.
294
+ * @param scope - which comment authors the caller may act on.
219
295
  * @param ids - the comments to drop; a repeated id is one comment.
220
296
  * @returns the ids that were dropped, in the order given.
221
297
  */
222
- removeMany(sessionId: SessionId, ids: readonly string[]): string[];
298
+ removeMany(scope: CommentScope, ids: readonly string[]): string[];
223
299
  /**
224
300
  * Every comment one pending entry carries, oldest first.
225
301
  *
@@ -240,17 +316,29 @@ export declare class CommentStore {
240
316
  */
241
317
  removeForEntry(entryId: string): number;
242
318
  /**
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.
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
+ *
252
340
  * @param entryIds - every entry id the pending store currently holds.
253
- * @returns how many orphaned comments were dropped.
341
+ * @returns how many comments the view dropped.
254
342
  */
255
343
  retain(entryIds: ReadonlySet<string>): number;
256
344
  /**
@@ -264,7 +352,14 @@ export declare class CommentStore {
264
352
  * marker, the reference and the rules, and a thread that showed the prompt would show
265
353
  * the scaffolding rather than the question.
266
354
  *
267
- * @param sessionId - the session the comment belongs to.
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.
268
363
  * @param id - the comment that was asked in.
269
364
  * @param requestId - the identity the submission carries into the transcript.
270
365
  * @param turn - the turn that claimed it, when that is already known.
@@ -276,20 +371,24 @@ export declare class CommentStore {
276
371
  * Record the turn that claimed one question, as the agent's inbox reported it. Two
277
372
  * questions claimed by the same turn were answered together, which is a fact the
278
373
  * panel can state instead of guessing from transcript positions.
279
- * @param sessionId - the session the submission belongs to.
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.
280
378
  * @param requestId - the request identity the inbox reported back.
281
379
  * @param turn - the turn that claimed it.
282
380
  * @returns how many threads the patch touched.
283
381
  */
284
- recordTurnForRequest(sessionId: SessionId, requestId: string, turn: number): number;
382
+ recordTurnForRequest(requestId: string, turn: number): number;
285
383
  /**
286
- * Record that the session dropped one question before a turn claimed it: nothing is
384
+ * Record that a session dropped one question before a turn claimed it: nothing is
287
385
  * coming, and the reader is told rather than left waiting.
288
- * @param sessionId - the session the submission belongs to.
386
+ *
387
+ * Matched by REQUEST ID alone, for the same reason as `recordTurnForRequest`.
289
388
  * @param requestId - the request identity the inbox reported back.
290
389
  * @returns how many threads the patch touched.
291
390
  */
292
- markDroppedForRequest(sessionId: SessionId, requestId: string): number;
391
+ markDroppedForRequest(requestId: string): number;
293
392
  /**
294
393
  * Record that a turn is over, so the questions it claimed stop looking pending.
295
394
  *
@@ -301,7 +400,11 @@ export declare class CommentStore {
301
400
  * that: whether an answer exists is read from the transcript on each list read, so a
302
401
  * turn that stopped AFTER writing its answer keeps showing the answer.
303
402
  *
304
- * @param sessionId - the session whose turn ended.
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.
305
408
  * @param turn - the turn that stopped.
306
409
  * @returns how many threads the patch touched.
307
410
  */
@@ -340,7 +443,12 @@ export declare class CommentStore {
340
443
  * memory-only view would TRUNCATE that copy — turning a read failure into a permanent
341
444
  * loss — so the save is refused and reported instead, which is what tells the reader
342
445
  * those comments are memory-only.
343
- * @param sessionId - the session whose file to rewrite.
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.
344
452
  */
345
453
  private save;
346
454
  /**
@@ -35,7 +35,7 @@
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, CommentAnchor, CommentQuoteLine, CommentRecord, DiffApprovalCommentAddValue, DiffApprovalCommentRemoveManyValue, DiffApprovalCommentRemoveValue, 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
41
  export { CommentStore, commentsDirFor } from './comments.ts';