dsh-rewind-plugin 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * dsh-rewind flow half: the executed rewind command and its composer refill.
3
+ *
4
+ * This is the BEHAVIOR layer, deliberately free of any mounting decision. Both
5
+ * the `/rewind` text flow (command decoration → mode popover) and, in A2, the
6
+ * per-message action call {@link runRewindAndFill}; nothing here portals,
7
+ * observes rows, or knows where the trigger sits.
8
+ *
9
+ * The refill is event-driven: it runs only when THIS page performed the
10
+ * rewind (the user confirmed moments ago). It must NEVER scan loaded history
11
+ * for rewind commands — a session window opens with only the tail page, so
12
+ * "a command already in the snapshot" cannot be told apart from "executed in
13
+ * this page", and the old baseline heuristic refilled withdrawn text into the
14
+ * composer after switching sessions or restarting dsh.
15
+ *
16
+ * @module dsh-rewind/client/rewind-flow
17
+ */
18
+ import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client';
19
+ import { type ChatOf, type ChatWatch, type MessageImageRef } from './hidden.ts';
20
+ /**
21
+ * The session input facade's write face (structural, so the plugin
22
+ * never imports the conversation UI package). `setDraft` replaces the whole
23
+ * composer draft through the harness's own Lexical editor — the correct way
24
+ * to restore the withdrawn text.
25
+ */
26
+ interface ComposerDraftWriter {
27
+ setDraft(text: string): void;
28
+ /**
29
+ * Return the keyboard to the composer with the caret it last held (the
30
+ * facade's own `focus`). Optional so the DOM fallback and older faces still
31
+ * restore the text.
32
+ */
33
+ focus?(): void;
34
+ }
35
+ /**
36
+ * Fill the dsh composer with `text` through the `contenteditable`
37
+ * DOM path. Used by `setComposerText` (the harness-facade-aware writer) as the
38
+ * last-resort and by `runRewindAndFill` to put the withdrawn target message
39
+ * back into the composer after a rewind. Best-effort — no composer match means
40
+ * false, never a throw.
41
+ */
42
+ export declare function fillComposer(text: string): boolean;
43
+ /**
44
+ * Composer write: prefer the harness facade's `setDraft` (correct whole-draft
45
+ * replace), then degrade to the DOM `fillComposer` (contenteditable). A facade
46
+ * that throws (session teardown) is treated as absent so the DOM path still
47
+ * restores the text. Never throws.
48
+ * @param text - the withdrawn target message text.
49
+ * @param facade - the session input draft writer, when reachable.
50
+ * @returns whether a channel applied the text.
51
+ */
52
+ export declare function writeComposer(text: string, facade: ComposerDraftWriter | undefined): boolean;
53
+ /**
54
+ * Execute one rewind and, when it settles successfully, put the withdrawn
55
+ * target message's text back into the composer so the user can edit and
56
+ * re-send.
57
+ */
58
+ export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', isMainViewSession: (sessionId: string) => boolean, chatOf: ChatOf, watchChat: ChatWatch, setComposerText: (sessionId: string, text: string) => boolean, restoreImages?: (sessionId: string, images: readonly MessageImageRef[]) => Promise<void>): Promise<void>;
59
+ /** The current composer draft: the contenteditable `textContent`.
60
+ * Empty when the composer is absent. Exported as a test seam (the
61
+ * empty-composer guard in `runRewindAndFill`). */
62
+ export declare function composerText(): string;
63
+ /**
64
+ * The session-kind slice the durable-target gate reads — the session
65
+ * snapshot's `subagent` cell, the Harness's own runtime signal for a
66
+ * direct-subagent (child) session. Typed structurally so the plugin never
67
+ * imports the session-controller snapshot contract.
68
+ */
69
+ export interface SessionKindLike {
70
+ /** Non-null exactly while this Session is addressed as a subagent child. */
71
+ readonly subagent: unknown;
72
+ }
73
+ /**
74
+ * Whether a session is rewind-inert: a direct-subagent (child) session.
75
+ *
76
+ * The Harness refuses every generic Session RPC for a subagent-owned identity
77
+ * (`session/agent-busy`, "use subagent delivery for this child session"), so
78
+ * `/rewind` can never execute there — the command's own admission is the
79
+ * refusal, before any handler runs. A wired-up action in such a session would
80
+ * therefore be dead UI (SiriLee/dsh-rewind#26). This mirrors the Harness's own
81
+ * slash-command directory (which returns no commands for an addressed child)
82
+ * and the Host's `isSubagentSession` skips: a child session gets no rewind
83
+ * surface and records no snapshot.
84
+ */
85
+ export declare function isRewindInertSession(snapshot: SessionKindLike): boolean;
86
+ export {};
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Client plugin styling: one injected `<style>` tag (scoped class names),
3
- * following the dsh design tokens (`--dsw-*`) so the button and popover blend
4
- * with the conversation chrome.
3
+ * following the dsh design tokens (`--dsw-*`) so the per-message action, the
4
+ * mode popover, the failure toast and the settings card blend with the
5
+ * conversation chrome.
5
6
  *
6
7
  * @module dsh-rewind/client/styles
7
8
  */
8
9
  /** Class names shared between the injected DOM and the stylesheet. */
9
10
  export declare const CLASS: {
10
- readonly button: 'dsh-rewind-btn';
11
+ readonly action: 'dsh-rewind-action';
11
12
  readonly popover: 'dsh-rewind-popover';
12
13
  readonly popoverTitle: 'dsh-rewind-popover-title';
13
14
  readonly popoverTarget: 'dsh-rewind-popover-target';
@@ -21,15 +22,5 @@ export declare const CLASS: {
21
22
  readonly popoverGhost: 'dsh-rewind-popover-ghost';
22
23
  readonly guardHint: 'dsh-rewind-guard-hint';
23
24
  };
24
- /**
25
- * The ↶ glyph, drawn inline so the bundle stays dependency-free.
26
- *
27
- * Size follows the 0.1.7 product-icon set (15px, as the neighbouring copy/branch
28
- * actions in `MessageIconActions`). Stroke is 1.25 rather than upstream's
29
- * `Regular` 1: ↶ is a single open stroke next to a dense outline-plus-fill copy
30
- * glyph, so at 1 it reads thinned out. Rounded caps/joins stay — upstream keeps
31
- * them on curved artwork.
32
- */
33
- export declare const REWIND_ICON_SVG: string;
34
25
  /** One injected stylesheet (scoped under `.dsh-rewind-*`). */
35
- export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n /* The neighbouring action buttons scale by this delta too. */\n width: calc(28px + var(--dsh-content-font-delta, 0px));\n height: calc(28px + var(--dsh-content-font-delta, 0px));\n padding: 6px;\n border: none;\n border-radius: var(--dsw-radius-sm, 8px);\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn svg {\n width: calc(15px + var(--dsh-content-font-delta, 0px));\n height: calc(15px + var(--dsh-content-font-delta, 0px));\n}\n@media (hover: hover) {\n .dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n }\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1100;\n box-sizing: border-box;\n width: min(300px, calc(100vw - 24px));\n padding: 16px;\n border: 0;\n border-radius: var(--dsw-radius-lg);\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n backdrop-filter: var(--dsw-menu-backdrop-filter);\n isolation: isolate;\n --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);\n /* Elevated surface: match the menu cards' l2 scrollbar tokens. */\n --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);\n --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);\n box-shadow: var(--dsw-elevation-prominent);\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 12px;\n font-weight: 500;\n line-height: 18px;\n}\n.dsh-rewind-popover-target {\n margin: 2px 0 10px;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n /* The full message sits above the popover; two lines are enough context. */\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 2px;\n padding: 6px 8px;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n/* Arrow navigation moves real focus, so the focused row carries the pointer's\n fill and the browser ring is suppressed: in dark mode the UA two-tone ring\n draws its dark half against the filled row and reads as a residue edge (the\n harness Menu suppresses it the same way). */\n.dsh-rewind-popover-option:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n outline: none;\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-note {\n margin-top: 6px;\n font-size: 11px;\n line-height: 15px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n margin-top: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n height: 28px;\n padding: 0 10px;\n border: none;\n border-radius: 8px;\n font: inherit;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-ghost:active {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 0;\n border-radius: var(--dsw-radius-md);\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n backdrop-filter: var(--dsw-menu-backdrop-filter);\n isolation: isolate;\n --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);\n box-shadow: var(--dsw-elevation-panel);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n\n/* ---- Plugin configuration form ---- */\n/* Only the card's own controls: the two switch rows, their hints, and the divider. */\n.dsh-rewind-settings-divider {\n border-top: 1px solid var(--dsw-alias-border-l2);\n}\n.dsh-rewind-settings-section {\n display: grid;\n gap: 6px;\n padding: 12px 0;\n}\n.dsh-rewind-settings-toggle-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-settings-toggle-label {\n flex: 1;\n min-width: 0;\n}\n.dsh-rewind-settings-hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n";
26
+ export declare const STYLE = "\n/* The official action chrome, value for value: the action is Copy's peer in the\n same 28px row, so a different box, glyph scale, or resting tier would read as\n a different kind of control. */\n.dsh-rewind-action {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: calc(28px + var(--dsh-content-font-delta, 0px));\n height: calc(28px + var(--dsh-content-font-delta, 0px));\n padding: 6px;\n border: none;\n border-radius: var(--dsw-radius-sm, 8px);\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-action svg {\n width: calc(15px + var(--dsh-content-font-delta, 0px));\n height: calc(15px + var(--dsh-content-font-delta, 0px));\n}\n@media (hover: hover) {\n .dsh-rewind-action:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n }\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1100;\n box-sizing: border-box;\n width: min(300px, calc(100vw - 24px));\n padding: 16px;\n border: 0;\n border-radius: var(--dsw-radius-lg);\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n backdrop-filter: var(--dsw-menu-backdrop-filter);\n isolation: isolate;\n --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);\n /* Elevated surface: match the menu cards' l2 scrollbar tokens. */\n --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);\n --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);\n box-shadow: var(--dsw-elevation-prominent);\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 12px;\n font-weight: 500;\n line-height: 18px;\n}\n.dsh-rewind-popover-target {\n margin: 2px 0 10px;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n /* The full message sits above the popover; two lines are enough context. */\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 2px;\n padding: 6px 8px;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n/* Arrow navigation moves real focus, so the focused row carries the pointer's\n fill and the browser ring is suppressed: in dark mode the UA two-tone ring\n draws its dark half against the filled row and reads as a residue edge (the\n harness Menu suppresses it the same way). */\n.dsh-rewind-popover-option:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n outline: none;\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-note {\n margin-top: 6px;\n font-size: 11px;\n line-height: 15px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n margin-top: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n height: 28px;\n padding: 0 10px;\n border: none;\n border-radius: 8px;\n font: inherit;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-ghost:active {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 0;\n border-radius: var(--dsw-radius-md);\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n backdrop-filter: var(--dsw-menu-backdrop-filter);\n isolation: isolate;\n --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);\n box-shadow: var(--dsw-elevation-panel);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n\n/* ---- Plugin configuration form ---- */\n/* Only the card's own controls: the two switch rows, their hints, and the divider. */\n.dsh-rewind-settings-divider {\n border-top: 1px solid var(--dsw-alias-border-l2);\n}\n.dsh-rewind-settings-section {\n display: grid;\n gap: 6px;\n padding: 12px 0;\n}\n.dsh-rewind-settings-toggle-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-settings-toggle-label {\n flex: 1;\n min-width: 0;\n}\n.dsh-rewind-settings-hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n";
@@ -0,0 +1,479 @@
1
+ /**
2
+ * On-disk store format — the single source of truth for what a snapshot
3
+ * directory looks like.
4
+ *
5
+ * Everything here is stateless and independent of the file-system service:
6
+ * the naming functions, the entry/journal JSON codecs, and the ref validators
7
+ * that keep a hostile or corrupt record inside the store. The byte format
8
+ * itself is specified in `docs/format.md`; the fail-closed store-version
9
+ * contract is enforced by `readEntry` here and by
10
+ * `SnapshotStore.assertKnownStoreVersion` in `snapshot.ts`.
11
+ *
12
+ * @module dsh-rewind/snapshot-format
13
+ */
14
+ /** Suffix of the raw byte copy holding one entry's before-content. */
15
+ export declare const SIDECAR_SUFFIX = ".before";
16
+ /** Session-dir sub-directory holding captures that are staged but not committed. */
17
+ export declare const PENDING_DIR = ".pending";
18
+ /** Session-dir sub-directory holding one pre-restore ("rescue") copy per op. */
19
+ export declare const RESCUE_DIR = "rescue";
20
+ /** Journal-file prefix of the current format. */
21
+ export declare const JOURNAL_PREFIX = "journal-";
22
+ /** Journal-file prefix written by the released v1 build (read-only compatibility). */
23
+ export declare const LEGACY_JOURNAL_PREFIX = "restore-journal-";
24
+ /**
25
+ * Current on-disk store format version (the session's `store` marker value and
26
+ * the `store` field of every entry this build writes). A value ABOVE this one
27
+ * means the snapshots were written by a NEWER build: readers must then fail
28
+ * closed (no file restore, nothing changed) instead of guessing what the extra
29
+ * fields mean. A missing marker (or `1`) means the released v1 string format,
30
+ * which is still read.
31
+ */
32
+ export declare const CURRENT_STORE_VERSION = 2;
33
+ /**
34
+ * Thrown when snapshots carry a store format newer than this build understands
35
+ * (ADR-10: whole-operation fail-closed — never a partial restore, never a
36
+ * clear). The session rewind itself does not depend on snapshots and still
37
+ * works.
38
+ */
39
+ export declare class UnknownStoreVersionError extends Error {
40
+ /** The version found on disk. */
41
+ readonly version: number;
42
+ /** Where it was found (a marker or an entry file). */
43
+ readonly source: string;
44
+ constructor(
45
+ /** The version found on disk. */
46
+ version: number,
47
+ /** Where it was found (a marker or an entry file). */
48
+ source: string);
49
+ }
50
+ /**
51
+ * Recorded before-content — always bytes, never a decoded string:
52
+ *
53
+ * - `blob`: a raw byte copy inside the store (the format every new write uses).
54
+ * - `text`: the exact UTF-8 bytes of a released-v1 string record (a v1 record
55
+ * that was decoded from valid UTF-8 is byte-exact, so restoring it is safe).
56
+ * - `lossyText`: a released-v1 string record that contains U+FFFD, i.e. one
57
+ * the v1 build produced by a LOSSY decode of non-UTF-8 bytes. The original
58
+ * bytes are unknowable, so it can be compared against the disk but must
59
+ * never be written back as a restore target (that would destroy live data).
60
+ */
61
+ export type ByteSource = {
62
+ readonly kind: 'blob';
63
+ readonly path: string;
64
+ } | {
65
+ readonly kind: 'text';
66
+ readonly bytes: Buffer;
67
+ } | {
68
+ readonly kind: 'lossyText';
69
+ readonly text: string;
70
+ };
71
+ /** Classify a released-v1 string record (U+FFFD means the decode lost bytes). */
72
+ export declare function textSourceOf(text: string): ByteSource;
73
+ /** A staged raw byte copy waiting to be committed (`recordBackup`). */
74
+ export interface PendingBackup {
75
+ /** Absolute path of the staged file (inside the session's `.pending/`). */
76
+ readonly file: string;
77
+ /** Byte size of the staged content. */
78
+ readonly size: number;
79
+ /**
80
+ * Permission bits of the source file (`stat().mode & 0o7777`), captured
81
+ * best-effort. Restored alongside the content — never on its own, and never
82
+ * as part of the change decision (ADR-9).
83
+ */
84
+ readonly mode?: number;
85
+ }
86
+ /** One committed before-backup, keyed by tool call. */
87
+ export interface CheckpointEntry {
88
+ readonly callId: string;
89
+ /** Seq of the user message anchoring the turn in which the change happened. */
90
+ readonly anchorSeq: number;
91
+ /** Resolved display path (absolute) of the tracked file. */
92
+ readonly path: string;
93
+ /** Byte source of the content before the change; null when the file was created. */
94
+ readonly before: ByteSource | null;
95
+ /** Byte size of `before` (0 when the file was created). */
96
+ readonly size: number;
97
+ /**
98
+ * Checkpoint-time location pin: where `dirname(path)` resolved when the entry
99
+ * was captured — or, when that directory was already gone, where it would
100
+ * have resolved (nearest surviving ancestor plus the missing segments). A
101
+ * restore refuses the path when its parent directory no longer resolves there,
102
+ * because a repointed or moved ancestor would otherwise redirect the write
103
+ * (or the unlink) outside the recorded location; the path's FINAL component is
104
+ * checked separately (see {@link isUnsafeTarget}). Absent means "no pin": a
105
+ * released-v1 entry or a legacy v2 entry, whose restore falls back to the
106
+ * final-component check alone.
107
+ */
108
+ readonly parent?: string;
109
+ /**
110
+ * Permission bits recorded at capture time, when known. Applied only when
111
+ * the CONTENT is restored (a mode-only difference is never a reason to plan
112
+ * a restore); a missing value means "leave the live mode alone".
113
+ */
114
+ readonly mode?: number;
115
+ /**
116
+ * Set when this entry's sidecar holds re-encoded LOSSY text (a released-v1
117
+ * record that was decoded with replacement characters, e.g. after `prune`
118
+ * materialized its link). The bytes are comparable but must never be written
119
+ * back: a reader turns such an entry into a `lossyText` source again.
120
+ */
121
+ readonly lossy?: boolean;
122
+ /** Epoch ms the entry was committed (stable ordering within a group). */
123
+ readonly time: number;
124
+ /**
125
+ * Absolute file this entry was READ from (in-memory only, never serialized):
126
+ * a dedup reference must name the file that actually exists, which for an
127
+ * entry written by an older build is not necessarily the name the current
128
+ * naming function would produce.
129
+ */
130
+ readonly file?: string;
131
+ }
132
+ /**
133
+ * One in-place dedup link, keyed by tool call. When a tracked file is
134
+ * recorded with a `before` content identical to the immediately-prior entry
135
+ * for that path, the entry is stored as a LINK instead of a full copy: it
136
+ * carries no content, only a `ref` naming the prior entry file
137
+ * (`<anchorSeq>/<callId>.json`). The linear (predecessor-chained) ref makes
138
+ * restore resolution and prune materialization rewrite-free.
139
+ */
140
+ export interface LinkEntry {
141
+ readonly callId: string;
142
+ readonly anchorSeq: number;
143
+ readonly path: string;
144
+ /** `<anchorSeq>/<callId>.json` of the immediately-prior entry for the path. */
145
+ readonly ref: string;
146
+ /**
147
+ * Checkpoint-time location pin (see {@link CheckpointEntry.parent}); a link
148
+ * records its own, so a materialized or resolved entry never loses the
149
+ * location the path was committed under.
150
+ */
151
+ readonly parent?: string;
152
+ readonly time: number;
153
+ /** Absolute file this link was read from (in-memory only, never serialized). */
154
+ readonly file?: string;
155
+ }
156
+ /** Any on-disk entry: a full before-backup or an in-place dedup link. */
157
+ export type StoredEntry = CheckpointEntry | LinkEntry;
158
+ /** True when an entry is a dedup link (carries `ref`, not `before`). */
159
+ export declare function isLinkEntry(entry: StoredEntry): entry is LinkEntry;
160
+ /** Per-file restore impact preview (served by the host preview route). */
161
+ export interface FileImpact {
162
+ readonly path: string;
163
+ /** `restore` = write the before content back; `delete` = remove the file. */
164
+ readonly action: 'restore' | 'delete';
165
+ }
166
+ /** A recorded path whose current on-disk size is over the backup cap. */
167
+ export interface UncoveredPath {
168
+ readonly path: string;
169
+ /** Byte size observed on disk, when the probe could read one. */
170
+ readonly size?: number;
171
+ }
172
+ /** Outcome of one restore pass. */
173
+ export interface RestoreOutcome {
174
+ readonly restored: readonly string[];
175
+ readonly deleted: readonly string[];
176
+ /** Symlinked or hard-linked paths left untouched. */
177
+ readonly skipped: readonly string[];
178
+ readonly failed: readonly {
179
+ path: string;
180
+ message: string;
181
+ }[];
182
+ }
183
+ /** Deletes one file by its real path (node:fs, bypassing the fs service). */
184
+ export type DeleteFile = (path: string) => Promise<void>;
185
+ /**
186
+ * Test-only fault injection: a crash point inside the write/restore paths.
187
+ * The hook THROWS to simulate a host crash at the exact point; the throw
188
+ * propagates out of the store method, leaving the journal on disk in its
189
+ * current state. Production callers never pass it (undefined = no-op).
190
+ */
191
+ export type CrashPoint = 'before-action' | 'after-action' | 'after-temp-write';
192
+ /** Options for the journaled restore paths; `crash` is the test-only seam. */
193
+ export interface RestoreRunOptions {
194
+ /**
195
+ * Throws at the given point to simulate a host crash: `before-action`
196
+ * (before an action's fs op), `after-action` (right after the fs op,
197
+ * before its done-mark is persisted), `after-temp-write` (inside an atomic
198
+ * commit, between the temp write and the rename). `index` is the action
199
+ * index for the restore loops.
200
+ */
201
+ readonly crash?: (point: CrashPoint, index?: number) => void;
202
+ }
203
+ /**
204
+ * Lifecycle of one restore operation journal. Terminal states are kept on
205
+ * disk as a tiny audit trail and skipped by reconciliation.
206
+ */
207
+ export type RestoreJournalState = 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'recovery-required';
208
+ /**
209
+ * One journaled restore action — a mutable working record that the restore
210
+ * loop updates (done/failed) as it applies the pass.
211
+ */
212
+ export interface RestoreJournalAction {
213
+ readonly path: string;
214
+ readonly action: 'restore' | 'delete';
215
+ /** Target content for a restore; null for a delete. */
216
+ readonly before: ByteSource | null;
217
+ /**
218
+ * Pre-restore disk state ("rescue"): a raw byte copy of what the file had
219
+ * right before the restore started, or null when it was absent. Rollback
220
+ * writes this back, so the pre-restore state is recoverable exactly.
221
+ */
222
+ readonly rescue: ByteSource | null;
223
+ /** Set when the rescue capture failed: rollback then skips this path. */
224
+ rescueError?: string;
225
+ /** Permission bits the restored content should end up with, when recorded. */
226
+ readonly mode?: number;
227
+ /**
228
+ * Checkpoint-time location pin copied from the entry the action was planned
229
+ * from (see {@link CheckpointEntry.parent}): a continue or rollback after a
230
+ * restart re-checks it before touching the path.
231
+ */
232
+ readonly parent?: string;
233
+ /** Permission bits the file had BEFORE the restore, for a faithful rollback. */
234
+ readonly rescueMode?: number;
235
+ /** True once the action's fs op completed and was marked. */
236
+ done: boolean;
237
+ /** Per-action failure message; the restore pass never aborts. */
238
+ failed?: string;
239
+ }
240
+ /** Durable journal for one attempted restore (written atomically). */
241
+ export interface RestoreJournal {
242
+ /** On-disk schema version: 2 = byte references, 1 = inline legacy strings. */
243
+ readonly version: 1 | 2;
244
+ readonly id: string;
245
+ readonly sessionId: string;
246
+ readonly targetSeq: number;
247
+ readonly startedAt: number;
248
+ finishedAt?: number;
249
+ state: RestoreJournalState;
250
+ readonly actions: RestoreJournalAction[];
251
+ /** Set when a rollback pass failed partway (state becomes `recovery-required`). */
252
+ rollbackError?: string;
253
+ /**
254
+ * Absolute file this journal was read from (in-memory only, never
255
+ * serialized): a legacy `restore-journal-` file is updated IN PLACE so an
256
+ * op that was interrupted before the upgrade never ends up with two
257
+ * divergent versions on disk.
258
+ */
259
+ sourceFile?: string;
260
+ }
261
+ /**
262
+ * Result of reconciling one interrupted restore journal against the real
263
+ * disk. Path status is relative to the op's current goal: the restore target
264
+ * for `running` journals, the pre-restore (rescue) state for
265
+ * `rollback-running` / `recovery-required` journals — disambiguate with
266
+ * {@link RestoreReconcileReport.journalState}.
267
+ */
268
+ export interface RestoreReconcileReport {
269
+ readonly opId: string;
270
+ /** `interrupted` = a crash left the op unfinished; `recovery-required` = a rollback could not complete. */
271
+ readonly state: 'interrupted' | 'recovery-required';
272
+ /** Raw journal state (`running` | `rollback-running` | `recovery-required`). */
273
+ readonly journalState: RestoreJournalState;
274
+ readonly targetSeq: number;
275
+ readonly startedAt: number;
276
+ /** Paths whose disk already matches the op's goal. */
277
+ readonly restored: readonly string[];
278
+ /** Paths still short of the op's goal (not yet applied / not yet rolled back). */
279
+ readonly pending: readonly string[];
280
+ /** Actions that failed during the pass (kept failed until a redo succeeds). */
281
+ readonly failed: readonly {
282
+ path: string;
283
+ message: string;
284
+ }[];
285
+ readonly rollbackError?: string;
286
+ /** Set when the journal file itself is corrupt: it cannot be reconciled. */
287
+ readonly corrupt?: string;
288
+ }
289
+ /**
290
+ * Current-on-disk state probe used by restore planning and reconciliation.
291
+ * Injected so the logic runs against a fake FS in tests; the production
292
+ * default reads the real file system with plain `node:fs` (see
293
+ * {@link defaultProbe}) and compares byte streams, never whole files in memory.
294
+ */
295
+ export interface DiskProbe {
296
+ /**
297
+ * Compare the recorded content with the file currently at `path`.
298
+ *
299
+ * - `true` = the disk matches the record byte-for-byte (for a `null`
300
+ * source: the path is absent).
301
+ * - `false` = it differs — including "the record says the file did not
302
+ * exist but it does" and "the record has content but the file is gone".
303
+ * - `undefined` = the comparison could not be decided (IO/permission
304
+ * failure). Callers stay conservative: a restore is still attempted and a
305
+ * delete still attempted, so an unreadable file is never silently skipped.
306
+ *
307
+ * A `lossyText` source is compared with the same lossy decode the released
308
+ * v1 build used (its original bytes cannot be recovered).
309
+ */
310
+ matches(source: ByteSource | null, path: string): Promise<boolean | undefined>;
311
+ /**
312
+ * Stage a raw byte copy of the file at `path` into `dest` (the store's
313
+ * rescue area) without loading it into memory.
314
+ */
315
+ copy(path: string, dest: string): Promise<CopyOutcome>;
316
+ /**
317
+ * Byte size of the regular file at `path`, or `undefined` when absent or
318
+ * unreadable. Consulted before any content comparison so an over-cap file is
319
+ * skipped without being read. Optional; a missing accessor reads as "size
320
+ * unknown", which never counts as "too big".
321
+ */
322
+ size?(path: string): Promise<number | undefined>;
323
+ /**
324
+ * True when the path's final component is not a single regular file: a
325
+ * symlink, a hard link (`nlink > 1`), a directory, a FIFO or a device. The
326
+ * planner and the apply pass both refuse one — a link redirects the write,
327
+ * a hard link shares the inode with other names, and a FIFO or device is
328
+ * neither restorable nor safe to open.
329
+ */
330
+ isUnsafeTarget(path: string): Promise<boolean>;
331
+ }
332
+ /** Result of staging one on-disk byte copy. */
333
+ export type CopyOutcome = {
334
+ readonly kind: 'copied';
335
+ readonly size: number;
336
+ } | {
337
+ readonly kind: 'absent';
338
+ } | {
339
+ readonly kind: 'failed';
340
+ readonly message: string;
341
+ };
342
+ /** One restore action the planner derived from record + disk reconciliation. */
343
+ export type PlannedAction = {
344
+ readonly path: string;
345
+ readonly action: 'restore';
346
+ readonly before: ByteSource;
347
+ readonly mode?: number;
348
+ readonly parent?: string;
349
+ } | {
350
+ readonly path: string;
351
+ readonly action: 'delete';
352
+ readonly parent?: string;
353
+ };
354
+ /** Sanitize a call id into a safe file name. */
355
+ export declare function safeFileId(callId: string): string;
356
+ /**
357
+ * Sanitize a session id into a safe path segment. Real ids are harness-minted
358
+ * UUIDs (a no-op here), but a hostile or malformed id must never traverse out
359
+ * of the snapshot root — `.` and `..` are the only bare values the charset
360
+ * permits that would alias the root or its parent.
361
+ */
362
+ export declare function safeSessionId(sessionId: string): string;
363
+ /**
364
+ * Structural validation of a parsed journal. Unlike checkpoint entries (whose
365
+ * corruption is silently skipped), a corrupt journal is reported
366
+ * fail-loud by `reconcileRestores` — silently dropping it would silently
367
+ * erase the ability to recover the interrupted restore.
368
+ *
369
+ * Accepts both formats: `version: 2` (byte references), the released
370
+ * `version: 1` (inline strings) and the legacy prefix that carried no
371
+ * `version` at all — but never best-effort-coerces a malformed action.
372
+ */
373
+ export declare function isRestoreJournal(value: unknown): value is RestoreJournal;
374
+ /**
375
+ * Format a store-relative filesystem path as a serialized ref. Refs are always
376
+ * POSIX-separated (`13/call.before`) even on Windows, where `relative()`
377
+ * returns backslashes; the journal and link validators only accept `/`.
378
+ */
379
+ export declare function sessionRelativeRef(sessionDir: string, file: string): string;
380
+ /** Serialized (on-disk) form of a journal action. */
381
+ export declare function journalToJson(journal: RestoreJournal, sessionDir: string): Record<string, unknown>;
382
+ /** Parse a validated journal's raw shape into the in-memory form. */
383
+ export declare function journalFromJson(raw: Record<string, unknown>, sessionDir: string): RestoreJournal | undefined;
384
+ /** True when a session-dir member is a restore journal (either prefix). */
385
+ export declare function isJournalName(name: string): boolean;
386
+ /** The op id encoded in a journal file name. */
387
+ export declare function journalOpIdOf(name: string): string;
388
+ /** The entry file name one call id maps to (the single naming function). */
389
+ export declare function entryFileName(callId: string): string;
390
+ /** Stable 8-hex digest of an arbitrary string (ids, paths, capture keys). */
391
+ export declare function shortHash(value: string): string;
392
+ /** The sidecar file name of one entry file (`<base>.json` → `<base>.before`). */
393
+ export declare function sidecarName(entryFile: string): string;
394
+ /** Anchor seq encoded in a link `ref` (`<anchorSeq>/<file>.json`). */
395
+ export declare function refAnchorOf(ref: string): number;
396
+ /** Serialize one in-memory entry for disk (the current byte format). */
397
+ export declare function entryToJson(entry: CheckpointEntry): Record<string, unknown>;
398
+ /** Serialize one in-memory dedup link for disk (the current byte format). */
399
+ export declare function linkToJson(link: LinkEntry): Record<string, unknown>;
400
+ /**
401
+ * Read one committed entry, or undefined when missing/corrupt. Accepts both
402
+ * formats: the current byte format (`store: 2`, `file`, `blob` + sidecar) and
403
+ * the released v1 format (`anchorSeq` + inline `before` string).
404
+ *
405
+ * The byte format deliberately does NOT carry `anchorSeq` (it equals the
406
+ * parent directory) and does NOT reuse the v1 key names — a downgraded v1
407
+ * build rejects such an entry instead of reading it as "created", which is
408
+ * what keeps a downgrade from deleting workspace files. See
409
+ * `tests/downgrade-safety.test.ts`.
410
+ */
411
+ export declare function readEntry(file: string, anchorSeq: number): Promise<StoredEntry | undefined>;
412
+ /**
413
+ * True when a dedup-link `ref` is a SAFE relative reference to a checkpoint
414
+ * entry — `<digits>/<callId>.json`, a single level below the session dir, with
415
+ * no traversal or absolute segment. The plugin always writes refs this way
416
+ * ({@link SnapshotStore.entryRefOf}); this validates a `ref` read back from
417
+ * disk so a hostile or corrupt ref can never escape the store root when a
418
+ * restore resolution or prune materialization follows it (mirrors the
419
+ * `safeSessionId` / `safeFileId` containment guarantee).
420
+ */
421
+ export declare function isSafeLinkRef(ref: string): boolean;
422
+ /**
423
+ * True when a journal's byte reference is SAFE and relative to the session
424
+ * dir: either `<digits>/<name>.before` (an entry sidecar) or
425
+ * `rescue/<opId>/<index>.before` (a rescue copy). Validated on both write and
426
+ * read, so a corrupt or hostile journal can never point a restore or a
427
+ * rollback outside the store (`..`, absolute paths and unknown roots are
428
+ * rejected).
429
+ */
430
+ export declare function isSafeBackupRef(ref: string): boolean;
431
+ /**
432
+ * Result of a stale-session cleanup sweep ({@link SnapshotStore.pruneStale}).
433
+ *
434
+ * The sweep is ANTI-DELETE: it only ever removes WHOLE session directories
435
+ * that have been idle past `maxAgeDays`. `scanned` counts every session dir
436
+ * evaluated; `kept` + `skippedActive` + `deleted` sum to it. `remainingBytes`
437
+ * is the total of directories that SURVIVE the policy (when `dryRun` it is the
438
+ * would-be total, not the current on-disk total), so it is comparable across
439
+ * dry and real runs.
440
+ */
441
+ export interface PruneStaleReport {
442
+ /** Number of session directories evaluated. */
443
+ readonly scanned: number;
444
+ /** Session directories removed (would-be count when `dryRun`). */
445
+ readonly deleted: number;
446
+ /** Bytes reclaimed (would-be bytes when `dryRun`). */
447
+ readonly freedBytes: number;
448
+ /** Session directories retained (not past the cutoff, not the active one). */
449
+ readonly kept: number;
450
+ /** Bytes across the retained + skipped-active directories. */
451
+ readonly remainingBytes: number;
452
+ /** Directories skipped because they are the active session. */
453
+ readonly skippedActive: number;
454
+ /** Whether nothing was really removed (the sweep only reported). */
455
+ readonly dryRun: boolean;
456
+ }
457
+ /**
458
+ * Result of a manual whole-session clear ({@link SnapshotStore.clearSession}).
459
+ *
460
+ * Unlike the age-based sweep, a clear removes EVERY snapshot of ONE session
461
+ * (all anchor groups, all checkpoint entries, all restore journals) on demand —
462
+ * the active session the user is driving, to drop the rewind overhead or to
463
+ * archive a conversation immediately. `dryRun` reports what would be removed
464
+ * without touching disk or memory.
465
+ */
466
+ export interface ClearSessionReport {
467
+ /** The session whose records were (or would be) cleared. */
468
+ readonly sessionId: string;
469
+ /** Number of anchor-group (user-message) directories present. */
470
+ readonly anchorGroups: number;
471
+ /** Number of committed checkpoint entries (full backups + dedup links). */
472
+ readonly entries: number;
473
+ /** Number of restore-journal files (terminal + pending). */
474
+ readonly journals: number;
475
+ /** Bytes occupied by the session directory (the amount freed). */
476
+ readonly bytes: number;
477
+ /** Whether nothing was really removed (the clear only reported). */
478
+ readonly dryRun: boolean;
479
+ }