dsh-diff-approval 0.30.1 → 0.32.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 (36) hide show
  1. package/README.md +11 -23
  2. package/README.zh.md +9 -21
  3. package/assets/fonts/README.md +10 -8
  4. package/assets/fonts/manifest.json +1 -1
  5. package/lib/client.js +2791 -894
  6. package/lib/index.js +1755 -269
  7. package/lib/types/annotate-skill.d.ts +1 -1
  8. package/lib/types/annotate-tool.d.ts +11 -0
  9. package/lib/types/annotate.d.ts +28 -0
  10. package/lib/types/client/PendingPanel.d.ts +31 -21
  11. package/lib/types/client/category-color.d.ts +62 -0
  12. package/lib/types/client/chords.d.ts +70 -17
  13. package/lib/types/client/discussion.d.ts +10 -3
  14. package/lib/types/client/dock.d.ts +8 -3
  15. package/lib/types/client/dsh-icons.d.ts +39 -1
  16. package/lib/types/client/header-entry.d.ts +8 -7
  17. package/lib/types/client/locales.d.ts +52 -12
  18. package/lib/types/client/panel-memory.d.ts +24 -6
  19. package/lib/types/client/path-match.d.ts +50 -0
  20. package/lib/types/client/port.d.ts +29 -2
  21. package/lib/types/client/produced-diff.d.ts +83 -6
  22. package/lib/types/client/reference.d.ts +0 -2
  23. package/lib/types/client/remap-sync.d.ts +18 -1
  24. package/lib/types/client/session-seat.d.ts +47 -7
  25. package/lib/types/client/settings.d.ts +40 -14
  26. package/lib/types/client/slots.d.ts +115 -3
  27. package/lib/types/client/store.d.ts +50 -0
  28. package/lib/types/client/whole-file-diff.d.ts +42 -0
  29. package/lib/types/comment-ask.d.ts +47 -20
  30. package/lib/types/comments.d.ts +141 -33
  31. package/lib/types/index.d.ts +1 -1
  32. package/lib/types/pending.d.ts +30 -4
  33. package/lib/types/types.d.ts +186 -4
  34. package/lib/types/vcs.d.ts +35 -1
  35. package/lib/types/version.d.ts +162 -0
  36. package/package.json +4 -2
@@ -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';
@@ -51,10 +51,11 @@ export declare class PendingDiffStore {
51
51
  /** Fold a captured entry into the path's single global entry. */
52
52
  private merge;
53
53
  /**
54
- * Combine a stored entry and a new capture. `oldText` comes from the earlier
55
- * capture (the file's original basis), `newText` from the later (latest
56
- * content), and the session set is the union — so the entry always spans the
57
- * whole pending change regardless of which session contributed which part.
54
+ * Combine a stored entry and a new capture. `oldText` comes from the earlier capture (the file's
55
+ * original basis) — except where that capture was the file's creation and this one edits it, when
56
+ * the creation's own content becomes the basis — and `newText` from the later (latest content).
57
+ * The session set is the union, so the entry always spans the whole pending change regardless of
58
+ * which session contributed which part.
58
59
  */
59
60
  private mergeEntry;
60
61
  /**
@@ -82,6 +83,15 @@ export declare class PendingDiffStore {
82
83
  /**
83
84
  * Advance one entry's tracked content after a block-level keep/revert. The
84
85
  * entry keeps its path; only the given side's text and capture time move.
86
+ *
87
+ * A keep that advances `oldText` also ends the entry's life as a creation. `earlierVersion` is the fact
88
+ * "this file did not exist before", and once part of the file has been accepted there IS an earlier
89
+ * version to restore — what the reader kept. The whole-file action must therefore become 回退, not 删除:
90
+ * deleting would throw away the part they just accepted. (Leaving the field alone kept a partly-kept new
91
+ * file offering to delete itself, which is what sent the reader here.)
92
+ *
93
+ * Every caller of this is a reader action — a block keep or revert, a refresh from version control — so it
94
+ * also takes the unseen dot down: the reader has just handled this file.
85
95
  * @param path - the file path.
86
96
  * @param patch - the side to advance (`oldText` for keep, `newText` for revert).
87
97
  * @returns whether the entry changed.
@@ -90,6 +100,22 @@ export declare class PendingDiffStore {
90
100
  oldText?: string;
91
101
  newText?: string;
92
102
  }): boolean;
103
+ /**
104
+ * Mark paths the reader has now seen — the file was opened, or its row was acted on. The dot is about
105
+ * attention rather than content, so this and the entry leaving the list are the only ways it goes out, and
106
+ * nothing brings it back except a new capture.
107
+ * @param paths - one path, or several.
108
+ * @returns whether anything changed.
109
+ */
110
+ markSeen(paths: string | readonly string[]): boolean;
111
+ /**
112
+ * Raise the dot for paths whose content moved WITHOUT the reader asking — a writer outside the panel, a
113
+ * second process. The capture sites set the field on the entry they fold; this is for the changes the
114
+ * plugin notices by itself while refreshing, which do not come through a capture.
115
+ * @param paths - one path, or several.
116
+ * @returns whether anything changed.
117
+ */
118
+ markUnseen(paths: string | readonly string[]): boolean;
93
119
  /**
94
120
  * Restore one entry exactly as given (an undo/redo replays a snapshot). The
95
121
  * entry is inserted or replaced by path.
@@ -5,6 +5,26 @@
5
5
  */
6
6
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
7
7
  import type { CommentLineRange } from './comment-lines.ts';
8
+ /**
9
+ * Where a session sits in the lineage a merged view groups by.
10
+ *
11
+ * Read off the session's own header when an entry is CAPTURED (`SessionHeader.parentSession`, `.origin`,
12
+ * `.delegationDepth`, `.cwd`) and carried on the entry, so a client can group without a second wire
13
+ * field. Every part is optional and absent when the header — or that one field of it — was not
14
+ * available: an ordinary root session has no parent by design, and a host older than these header
15
+ * fields reports none of them. An unknown lineage is recorded as unknown; guessing a root would file a
16
+ * session under the wrong one. Nothing reads this yet: the record is written for the merged view to use.
17
+ */
18
+ export interface SessionLineage {
19
+ /** The session this one was forked from, or absent for an ordinary root. */
20
+ parentSessionId?: SessionId | undefined;
21
+ /** The header's coarse product classification — `'subagent'` for a subagent child. */
22
+ origin?: string | undefined;
23
+ /** How deep below its root the session was delegated, when the shell reports it. */
24
+ delegationDepth?: number | undefined;
25
+ /** The session's own working directory, when its header names one. */
26
+ cwd?: string | undefined;
27
+ }
8
28
  /**
9
29
  * One file's pending entry, global and unique per `path`: the complete set of
10
30
  * unhandled changes folded into a single cumulative span across every session
@@ -17,8 +37,12 @@ export interface PendingEntry {
17
37
  id: string;
18
38
  /** Backend-resolved display path (the tool's output `path`). */
19
39
  path: string;
20
- /** What the operation did: an in-place change or a file creation. */
21
- kind: PendingEntryKind;
40
+ /**
41
+ * Whether there is an earlier version of the file to restore: `'file'` when
42
+ * the whole-file action writes the earlier text back, `'none'` when the file
43
+ * did not exist, so the whole-file action deletes it.
44
+ */
45
+ earlierVersion: PendingEntryKind;
22
46
  /** File content before the first captured operation (empty for a creation). */
23
47
  oldText: string;
24
48
  /** File content after the latest captured operation. */
@@ -29,14 +53,70 @@ export interface PendingEntry {
29
53
  sessionId: SessionId;
30
54
  /** Every session that touched the file (drives the per-session list filter). */
31
55
  sessionIds: SessionId[];
56
+ /**
57
+ * Where this entry's session sits in its lineage, as the shell reported it when the entry was
58
+ * CAPTURED — display-only data for a merged view. The entry keeps its real `sessionId`/`sessionIds`
59
+ * either way: grouping by lineage never rewrites who did the work. Absent on a record written before
60
+ * this field existed, on a session whose header carried none of it, and on an ordinary root (which
61
+ * has no parent by design) — so a reader must treat absent as "not known", never as "a root".
62
+ */
63
+ lineage?: SessionLineage | undefined;
64
+ /**
65
+ * Whether a change the reader has not looked at yet arrived on this path — the dot beside its row. Set
66
+ * when an agent operation (or an agent-side admission) changes the entry, cleared when the reader opens
67
+ * the file or acts on the row, and deliberately NOT part of an undo snapshot: a snapshot restores
68
+ * CONTENT, and having seen something is not content.
69
+ */
70
+ unseen?: boolean | undefined;
32
71
  }
33
- /** What one captured operation did to the file. */
34
- export type PendingEntryKind = 'edit' | 'create';
72
+ /** Whether an entry has an earlier version of its file to restore. */
73
+ export type PendingEntryKind = 'file' | 'none';
74
+ /**
75
+ * Which way a row's other contributor sits relative to the session LISTING it, walked from the recorded
76
+ * subagent parent links (see `lineageView` on the host).
77
+ *
78
+ * `child` — the other session descends from the lister (the lister is on the other's upward chain).
79
+ * `parent` — the other session is ABOVE the lister (the lister descends from it). It means an ANCESTOR,
80
+ * possibly more than one hop up, not only the direct parent: the Chinese 上级 says exactly that without
81
+ * ambiguity, so the English copy and this name keep that meaning.
82
+ * `sibling` — same lineage root, neither is on the other's chain (two children of one parent, say).
83
+ * `mixed` — non-requester owners of DIFFERENT directions: never one of them, and never a guess at one.
84
+ */
85
+ export type LineageDirection = 'child' | 'parent' | 'sibling' | 'mixed';
35
86
  /**
36
87
  * One listed pending entry: the stored operation plus the live file state the
37
88
  * list endpoint computes by reading the file's current content.
38
89
  */
39
90
  export interface PendingFileDiff extends PendingEntry {
91
+ /**
92
+ * Whether this row reached the list through the LINEAGE MERGE rather than because the LISTING session
93
+ * itself touched it. The HOST computes it per read (`listWithState`), because only the host can walk a
94
+ * lineage — the client cannot, and must not try: it is what lets the panel show a child session's row
95
+ * without re-deriving the merge in the browser. Absent on a host older than the merge and absent on a
96
+ * row this session touched, so a client that does not know the field behaves exactly as it did before.
97
+ */
98
+ viaLineage?: boolean | undefined;
99
+ /**
100
+ * Whether some session OTHER than the listing one — and inside the listing session's lineage — also
101
+ * contributed to this row, so a row the session touched itself still says a child had a hand in it. Kept
102
+ * apart from `viaLineage` on purpose: that one answers "why is this row here" (visibility), this one
103
+ * answers "whose change does it carry" (the mark). Only a session in the same lineage counts: entries are
104
+ * keyed by path globally, so an unrelated session's touch of the same file must never be called a child's
105
+ * work. Host-computed per read (`listWithState`), absent on a host older than the mark.
106
+ */
107
+ hasChildContribution?: boolean | undefined;
108
+ /**
109
+ * WHICH WAY the row's other contributor stands, so the marker's sentence tells the truth from the seat it
110
+ * is drawn in: the same row is a child's change in its parent's panel and a parent's change in its
111
+ * child's. Host-computed per read from the SAME recorded parent links the merge walks, and carried to the
112
+ * browser like `viaLineage` — the client never infers lineage.
113
+ *
114
+ * Absent whenever no mark applies (the lister is the only contributor), and absent on a host older than
115
+ * this field — in which case the client says the neutral sentence rather than picking a direction.
116
+ * `mixed` is likewise the honest answer when the row carries non-requester owners of different
117
+ * directions; it is never resolved to one of them.
118
+ */
119
+ lineageDirection?: LineageDirection | undefined;
40
120
  /**
41
121
  * Whether the file no longer exists (or cannot be resolved). Reverting a
42
122
  * missing entry restores its old content, which recreates the file.
@@ -110,6 +190,47 @@ export interface DiffApprovalListValue {
110
190
  */
111
191
  commentAnswers: Record<string, string>;
112
192
  }
193
+ /**
194
+ * Value returned by the channel's `list-count` endpoint: how many rows a full `list` read for the same
195
+ * session would carry.
196
+ *
197
+ * The badge needs a number once a second, and a full read is enormously more than a number: it ships
198
+ * every visible entry's whole `oldText`+`newText` (measured at 6.77 MB across 302 stored entries, with
199
+ * single rows at 1.2-1.6 MB). So this is the LIGHT read — the same visibility rule (`lineageView().sees`
200
+ * for the requesting session) and the same `ensureLoaded` discipline as `list`, and nothing else.
201
+ *
202
+ * WHAT IT DELIBERATELY DOES NOT DO: it reads no file content (`ctx.fs.readText` is never called), so it
203
+ * computes no diff, adopts no external change, creates no undo checkpoint, sweeps no comments and does not
204
+ * persist anything. It sends no texts, no comment records, no `commentLines`, no revision and no errors —
205
+ * `count` is the whole answer.
206
+ *
207
+ * WHAT THAT COSTS THE CALLER: a count is not a list. It asks `stat` about each visible row (the cheap
208
+ * liveness signal) and drops a row whose file is GONE, which is the one state `list` also drops without
209
+ * reading — but a file that is present and merely unreadable cannot be told from a readable one without
210
+ * reading it, so it is counted here and dropped by the next full read. The number can therefore be one
211
+ * poll stale, and the next full read is what corrects it; nothing else in this value is authoritative.
212
+ */
213
+ export interface DiffApprovalListCountValue {
214
+ /** How many rows this session's next full `list` read would carry. */
215
+ count: number;
216
+ }
217
+ /**
218
+ * Value returned by the channel's `update-check` endpoint: whether a newer release of
219
+ * this package is published, and what changed since the installed one.
220
+ *
221
+ * The comparison itself is the HOST's (`src/version.ts`): it is the side that knows the
222
+ * installed version, and the only side that can reach the registry (the browser cannot —
223
+ * `registry.npmjs.org` sends no CORS header). `newer` is therefore an ANSWER, not a hint:
224
+ * the client never re-derives it, so exactly one rule decides "is this newer".
225
+ */
226
+ export interface DiffApprovalUpdateValue {
227
+ /** The installed version as this host read it; empty when it could not be read at all. */
228
+ current: string;
229
+ /** The published version; absent when the registry was unreachable or answered nonsense. */
230
+ latest?: string | undefined;
231
+ /** Whether `latest` is STRICTLY newer than `current`; always false when either is unparsable. */
232
+ newer: boolean;
233
+ }
113
234
  /** What the open endpoint asks the OS to do with a file. */
114
235
  export type DiffApprovalOpenAction = 'open' | 'reveal';
115
236
  /** Value returned by the channel's open endpoint. */
@@ -180,6 +301,9 @@ export type DiffApprovalRefreshOutcome =
180
301
  | 'unchanged'
181
302
  /** The file has no local VCS change (untracked-and-excluded, or already clean). */
182
303
  | 'no-change'
304
+ /** The scan saw no change AND the file's content equals its VCS baseline: the change is
305
+ * committed, so the entry's diff is a keep away from being out of date. */
306
+ | 'committed'
183
307
  /** No pending entry existed for the id. */
184
308
  | 'missing'
185
309
  /** The workspace is not inside a git/svn/p4 checkout. */
@@ -187,6 +311,9 @@ export type DiffApprovalRefreshOutcome =
187
311
  /** Value returned by the channel's vcs-refresh endpoint. */
188
312
  export interface DiffApprovalRefreshValue {
189
313
  outcome: DiffApprovalRefreshOutcome;
314
+ /** Set when the refresh already settled the entry (the `committed` outcome keeps it, with no
315
+ * diff left); absent for every outcome that leaves the decision to the panel. */
316
+ resolved?: boolean | undefined;
190
317
  }
191
318
  /** Value returned by the channel's keep and revert endpoints. */
192
319
  export interface DiffApprovalActionValue {
@@ -287,6 +414,20 @@ export interface CommentAsk {
287
414
  * has admitted anything.
288
415
  */
289
416
  requestId: string;
417
+ /**
418
+ * The SESSION THIS QUESTION WAS SUBMITTED INTO — the transcript that will carry its answer.
419
+ *
420
+ * It is not always the comment's own `sessionId`: once a lineage reads every seat's threads (`list`
421
+ * fans the read out across the lineage root), any seat may ask a question on a thread another seat
422
+ * wrote, and the prompt goes into the seat the human is using. The answer fold then has to know which
423
+ * transcript to read for which question, and turn numbers are only unique WITHIN one session — so this
424
+ * is what `markTurnEnded` matches on as well.
425
+ *
426
+ * Absent on a question asked before this field existed, when the only session a question could have
427
+ * gone to was the comment's own: the fold reads the comment's `sessionId` for those (see
428
+ * `askTranscript` in the comment store), which is exactly where their answers already are.
429
+ */
430
+ sessionId?: SessionId | undefined;
290
431
  /**
291
432
  * The question's own words, as the reader typed them — without the marker, the
292
433
  * `(path:lines)` reference and the answer rules that wrap them in the submitted
@@ -352,12 +493,53 @@ export interface CommentRecord {
352
493
  * something the reader said would put words in their mouth. Absent means the reader.
353
494
  */
354
495
  author?: 'agent' | undefined;
496
+ /**
497
+ * Which CLASS of annotation this one belongs to, as the calling agent named it (see
498
+ * `annotate-tool.ts`'s `category` argument).
499
+ *
500
+ * The panel draws it as a coloured round dot on the comment list's row, so an agent that annotates
501
+ * several passes or topics can be told apart at a glance. OPAQUE to every reader of this record: the
502
+ * panel hashes the string to pick a colour and shows the string itself in the mark's own label, and
503
+ * nothing parses it, orders it or validates its shape. Absent means "no class, no dot" — which is
504
+ * every comment the reader writes themselves, and every annotation written before this field existed.
505
+ * Short by construction (the tool trims it and caps it at `MAX_ANNOTATION_CATEGORY_CHARS`).
506
+ */
507
+ category?: string | undefined;
355
508
  /** Epoch milliseconds the comment was written. */
356
509
  createdAt: number;
357
510
  /** Epoch milliseconds of the last change to this record. */
358
511
  updatedAt: number;
359
512
  /** The questions asked in this thread, oldest first; empty until one is asked. */
360
513
  asks?: CommentAsk[] | undefined;
514
+ /**
515
+ * Whether something the reader has not looked at yet arrived in this thread — the dot beside its
516
+ * card. Set when an agent-authored annotation is written or when an answer lands in the thread,
517
+ * cleared when the card comes into view, and deliberately NOT part of an undo snapshot: a snapshot
518
+ * restores CONTENT, and having seen something is not content.
519
+ */
520
+ unseen?: boolean | undefined;
521
+ /**
522
+ * The answer text the reader has already been told about, keyed by question id.
523
+ *
524
+ * The answer's text is deliberately absent from this record (see the doc above) — the transcript is
525
+ * the one source
526
+ * of truth for what the agent said — but "is this the answer I have already seen?" cannot be
527
+ * answered from the transcript alone, because a read has nothing to compare against. This is the
528
+ * baseline the comparison takes: the host writes the current answer here when the card goes out of
529
+ * the reader's way, and an answer whose text differs from this is news. Persisted rather than held
530
+ * in memory, so a restart does not light a dot the reader had already cleared.
531
+ */
532
+ answerSeen?: Record<string, string> | undefined;
533
+ /**
534
+ * The latest answer text the host has observed for each question, keyed by question id.
535
+ *
536
+ * The other half of the comparison above, and kept apart from it on purpose: this follows what the
537
+ * agent said, while `answerSeen` follows what the reader has looked at. An answer whose text differs
538
+ * from its stored `answerSeen` entry is what lights the dot — and an answer that disappears leaves
539
+ * this empty rather than looking like a change, so a transcript that cannot be read never re-lights
540
+ * a thread the reader already read.
541
+ */
542
+ answerNow?: Record<string, string> | undefined;
361
543
  }
362
544
  /** Value returned by the channel's comment-add endpoint. */
363
545
  export interface DiffApprovalCommentAddValue {
@@ -34,7 +34,8 @@ export interface VcsRoot {
34
34
  export interface VcsChange {
35
35
  /** Absolute path of the changed file (inside the workspace). */
36
36
  path: string;
37
- kind: 'edit' | 'create';
37
+ /** Whether an earlier version of the file exists: `'file'` restores it, `'none'` deletes the file. */
38
+ earlierVersion: 'file' | 'none';
38
39
  /** Baseline (pre-change) content; empty for a brand-new file. */
39
40
  oldText: string;
40
41
  /** Working content; empty for a deleted file. */
@@ -104,6 +105,18 @@ export interface VcsImportInput {
104
105
  readText: VcsFileReader;
105
106
  signal?: AbortSignal | undefined;
106
107
  }
108
+ /** What a revision has to say about one path: its content, or that the path has no version at all.
109
+ * A FAILED read is neither: it throws (see {@link readVcsBaseline}). */
110
+ export type VcsBaseline =
111
+ /** The baseline carries this path; `text` is its content there. */
112
+ {
113
+ state: 'file';
114
+ text: string;
115
+ }
116
+ /** The baseline has no version of this path (a creation — there is nothing to restore). */
117
+ | {
118
+ state: 'none';
119
+ };
107
120
  /**
108
121
  * Find the VCS enclosing `start` by walking up the directory tree: the first
109
122
  * directory (deepest) holding a marker wins, with git > svn > p4 when several
@@ -120,4 +133,25 @@ export declare function detectVcsRoot(start: string): VcsRoot | undefined;
120
133
  * @returns the changes; an absent/unusable VCS surfaces as a thrown error.
121
134
  */
122
135
  export declare function listVcsChanges(input: VcsImportInput): Promise<VcsChange[]>;
136
+ /**
137
+ * Read one file's BASELINE content — the version every branch above reads to build a change's
138
+ * `oldText`: git's last commit (`HEAD`, when the scan could not see the change), svn's `BASE`, p4's
139
+ * `#have`. The commands are the same ones those branches run, so a caller that needs one file's
140
+ * pre-change content outside a scan (the per-file refresh's "was this committed?" check) does not
141
+ * invent a second way to ask the same question — or a second set of failure modes to reason about.
142
+ *
143
+ * The two answers stay distinguishable, exactly as they do in a scan: a baseline that carries no
144
+ * version of the path is {@link VcsBaseline} `none` (git is the only branch that can report it,
145
+ * through {@link readGitBlob}'s own absence pattern), while a read that FAILED — a refused command,
146
+ * a timeout, a missing client — throws, because "could not read" is not "there is nothing there".
147
+ *
148
+ * @param kind - the VCS to read.
149
+ * @param root - the repository/checkout/client root, as {@link detectVcsRoot} found it.
150
+ * @param absolutePath - the file's absolute path (inside the root's tree).
151
+ * @param shell - the deployment's shell executor.
152
+ * @param signal - the caller's abort signal.
153
+ * @returns the baseline content, or `none` when the baseline has no version of the path.
154
+ * @throws when the baseline read failed for any other reason.
155
+ */
156
+ export declare function readVcsBaseline(kind: VcsKind, root: string, absolutePath: string, shell: ShellExecutorLike, signal: AbortSignal | undefined): Promise<VcsBaseline>;
123
157
  export {};