# Architecture and Maintainer Notes

[English](ARCHITECTURE.en.md) | [中文](ARCHITECTURE.md)

This document is for maintainers and developers who need to understand data behavior. End users should start with the repository README.md, whose installation, usage, privacy, and limitation notes take precedence.

## Architecture boundaries

The plugin has a Host service half and a browser client half:

- The Host service in lib/index.js runs inside the DSH Web host, reads the workspace registry and session persistence, and exposes local HTTP routes.
- The browser client in lib/client.js registers the Session Archive settings.section and renders state and actions.
- Pure domain logic lives in lib/export.js, lib/import.js, lib/restore.js, lib/metadata.js, lib/search.js, lib/stats.js, lib/insights.js, lib/retention.js, lib/retention-service.js, lib/auto-retention.js, lib/lineage.js, and lib/workspace-bulk-archive.js. lib/persistence-compat.js narrows the current Host's handle-based reads to a private read-only view; lib/history.js owns legacy-snapshot inventory and preview authorization; lib/history-restore.js owns single-use restore-as-copy transactions; and lib/legacy-recycle.js projects old snapshots into the unified Recycle Bin and persists migration state. lib/trash.js owns the regular recycle catalog, lib/snapshot.js owns verified snapshots, and lib/recycle.js composes the ordinary recycle lifecycle.

The browser never reads session files directly. All reads and writes go through Host routes.

## Host routes

Current routes:

~~~text
GET  /plugins/dsh-archived-chats/state
GET  /plugins/dsh-archived-chats/stats
GET  /plugins/dsh-archived-chats/insights
GET  /plugins/dsh-archived-chats/workspace-archive/workspaces
POST /plugins/dsh-archived-chats/workspace-archive/preview
POST /plugins/dsh-archived-chats/workspace-archive/apply
POST /plugins/dsh-archived-chats/retention/policy
POST /plugins/dsh-archived-chats/retention/policy/preview
POST /plugins/dsh-archived-chats/retention/preview
POST /plugins/dsh-archived-chats/retention/apply
GET  /plugins/dsh-archived-chats/lineage
POST /plugins/dsh-archived-chats/history/capture
GET  /plugins/dsh-archived-chats/history
POST /plugins/dsh-archived-chats/history/preview
POST /plugins/dsh-archived-chats/history/preview/image
POST /plugins/dsh-archived-chats/history/restore/preview
POST /plugins/dsh-archived-chats/history/restore
POST /plugins/dsh-archived-chats/history/delete
POST /plugins/dsh-archived-chats/history/delete-all
POST /plugins/dsh-archived-chats/preview
POST /plugins/dsh-archived-chats/preview/image
POST /plugins/dsh-archived-chats/search
POST /plugins/dsh-archived-chats/export
POST /plugins/dsh-archived-chats/import/inspect
POST /plugins/dsh-archived-chats/import/restore
POST /plugins/dsh-archived-chats/metadata
GET  /plugins/dsh-archived-chats/trash
POST /plugins/dsh-archived-chats/trash/restore
POST /plugins/dsh-archived-chats/trash/purge
POST /plugins/dsh-archived-chats/trash/empty
POST /plugins/dsh-archived-chats/unarchive
POST /plugins/dsh-archived-chats/unarchive-all
POST /plugins/dsh-archived-chats/delete
POST /plugins/dsh-archived-chats/delete-all
~~~

Every mutating route, plus preview, preview/image, search, history/preview, and history/preview/image, requires the `x-dsh-archived-chats: 1` header. `GET /history` returns only bounded safe inventory. Compatibility snapshot images require both the snapshot identity and the complete projected descriptor to match. History routes remain a migration and read-compatibility layer; the browser no longer exposes a standalone History view.

Workspace archive lists only safe workspace summaries with at least one eligible chat. The browser may select one or more workspaces, but it still calls Preview separately for each workspace. Preview accepts exactly one workspace ID and creates a five-minute, single-use token/nonce for at most 2,000 ordered eligible IDs. The aggregate confirmation retains only nonempty previews; if every workspace became empty, the refreshed chooser returns without an empty-result confirmation. Apply accepts one workspace token and nonce at a time, never caller-selected IDs; the browser consumes those credentials in selection order and combines their safe results. Eligibility requires unarchived membership in the existing workspace, no non-idle Host agent, and an inspected live or persisted event log containing `turn/start`; an empty new-session window is classified as `session-empty`, while an inspection failure is `session-unavailable`, and both fail closed. On Hosts without agent status, a loaded session is conservatively considered live. Candidate inspection is bounded to eight concurrent reads. Each item revalidates membership, archive state, agent status, and conversation content inside the shared lifecycle queue, then invokes the public `workspaceRegistry.archiveSession()` receiver-bound to the registry. New chats after preview are excluded; an item that became live, empty, unavailable, archived, or detached is reported and skipped. Successful archives do not capture snapshots. This feature changes neither workspace membership nor a workspace path or directory, and never moves a chat across workspaces. A Host without public `archiveSession` returns `workspace-archive-unsupported` without mutation.

## State and local data

The state route joins archived sessions, workspace, tags, notes, and metadataUpdatedAt for the browser list. Tags and notes are stored only at:

~~~text
$DSH_HOME/plugin-data/archived-chats/metadata.json
$DSH_HOME/plugin-data/archived-chats/trash.json
$DSH_HOME/plugin-data/archived-chats/legacy-recycle.json
$DSH_HOME/plugin-data/archived-chats/retention.json
$DSH_HOME/plugin-data/archived-chats/snapshots/
~~~

Metadata, recycle catalogs, and the legacy-snapshot migration catalog are versioned. Writes serialize and atomically replace their documents through temporary files. An unreadable or unsupported `trash.json` is preserved byte-for-byte, hides no archived sessions, and disables recycle mutations. An unreadable `legacy-recycle.json` blocks legacy-snapshot migration operations rather than guessing state.

The stats route measures session directories with concurrency four, skips symbolic links, and caches results for 30 seconds. A measurement failure marks only that row unavailable; list and mutation actions continue. Delete invalidates the affected cache row.

Insights joins session measurement with a stream-verified snapshot inventory and counts repeated snapshot attachments only from validated SHA-256 descriptors. The browser keeps totals in summary cards and presents session/snapshot inventories only in bounded searchable dialogs. retention.json uses schema version 2 with an explicit recycleAutoDelete flag. Version-one documents remain readable, force that flag to false, and are not silently rewritten. Only Recycle Bin age contributes cleanup candidates; legacy snapshot-count, age, and quota fields do not schedule snapshot deletion. Policy preview issues a five-minute single-use token/nonce for enabling or shortening, bound to the current policy, proposed policy, and expired candidates. Policy save accepts { policy, confirmation: { token, nonce } } and revalidates under the lifecycle lock; the save request does not perform deletion itself. Automatic checks begin after startup recovery and repeat once a minute without overlap. Each candidate delegates to unified recycle purge, revalidating the setting and record inside its lifecycle lock before writing purge-pending intent. Plugin disposal stops new jobs; already-committed permanent deletions are retried. Legacy retention/preview and retention/apply manual APIs remain compatible, but the UI no longer has an extra manual cleanup-preview step. Lineage uses only durable parentSession edges, never rewrites headers, and resolves titles for at most 100 untitled active source nodes already included in the focused tree. Its 5,000-node cap bounds the PROJECTED graph, not the Host's store: `focusIds` narrows the output to archived and recycled chats plus their explaining context, so a store of 100,000 sessions with 25 archived projects 25 nodes. Header fields are coerced per node rather than validated whole-graph — an unanticipated `origin` value, an absent `createdAt`, a changed numeric type each degrade that node to a value the public LineageNode already allows, and only an unusable identity drops a row. A projection that rejected the whole graph for one unrecognized header let a Host-side change disable the panel with no plugin change at all; malformed workspace and recycle entries are skipped for the same reason.

## Preview and full-text search

Preview accepts visible archived IDs by default and only recycle-catalog IDs with explicit `scope: "trash"`; search remains archive-only. lib/search.js uses Harness append-origin message projection, so replacement copies are never indexed twice. User, assistant, reasoning, tool-call, and tool-result text is searchable, while preview returns bounded pages of structured segments and sanitized image descriptors.

The preview/image authorization sequence is fixed: first require POST and `x-dsh-archived-chats: 1`, then bounded-parse `sessionId` and `attachmentId`; next confirm that the session is still in the currently visible archive set, find an exact image-descriptor match in that session's canonical projection, and only then read bytes through the optional `attachments.readImage` service. Both preview and preview/image recheck visible archive state after asynchronous reads and immediately before sending a response, preventing an overlapping unarchive or delete from exposing stale content. Image bytes use `no-store` and `nosniff`; cross-session, non-archived, and unprojected references are rejected, and error responses never echo filesystem paths. A host without attachment-read capability returns `preview-image-unsupported`; this degrades images only and does not block text, Markdown, reasoning, tool, JSON, or code preview.

Cross-session persistence inspection is limited to four concurrent reads, stops scheduling batches once the hit limit is satisfied, and aborts an older browser request when a newer search starts. A broken session is reported in `skipped` while other hits still succeed. Canonical projection limits each segment to 256 Ki code points, each message to 1 Mi code points and 1,000 segments, and each session to 10,000 projected messages; unknown structured values are bounded by depth, node, and character budgets before stringify. A 30-second TTL, 64-session LRU, and per-session cache cap keep bounded projections resident. Unarchive, delete, and restore invalidate affected cache entries.

## Legacy-snapshot compatibility and unified recycling

Archiving no longer calls `history/capture`; that compatibility route refuses new History captures. Upgrades preserve existing snapshots. `legacy-recycle.js` projects snapshots not referenced by regular recycle records as `legacy:<snapshotId>` Recycle Bin items, so the browser displays them in the unified Recycle Bin. Existing History inventory, preview, restore, and delete routes remain for migration and compatible callers. Recycle Bin operations still capture protection snapshots required for recovery.

`history.js` groups published snapshots as `archived`, `recycled`, or `history-only`, inspects no more than 5,000 snapshot directories, shares one in-flight request, and caches completed inventory for 30 seconds. Inventory contains only safe title/workspace title, timestamps, sizes, attachment counts, and protection state; degraded entries expose only snapshot ID and a stable code. Paginated preview and image reads revalidate snapshot identity, digests, and complete descriptors without returning paths or raw records.

`history-restore.js` fully validates the snapshot, asks the Host for a new session ID, and issues a five-minute single-use token/nonce. Confirmation consumes the credential before writes and rechecks the manifest, then creates persistence, rewrites session/attachment identities, appends events, restores workspace and metadata, and commits archive registry state last. After commit, unified recycling marks the migration state `restore-complete`, then removes the restored snapshot and migration entry; a cleanup failure retains cleanup state without undoing the successfully created archived copy. Failures before commit reverse plugin-controlled steps. The source session and snapshot do not change before commit, and the plugin makes no claim that Host-global attachment objects were deleted.

Legacy-snapshot permanent deletion and Empty Recycle Bin both enter the shared lifecycle queue and bypass the ordinary 30-second cache/in-flight list so current snapshot and regular recycle-protection state is recomputed. A snapshot referenced by a regular recycle record is not also projected as a legacy item. A degraded snapshot cannot be previewed or restored, but it can be permanently deleted through the unified Recycle Bin so its bytes remain reclaimable. Deletion physically removes the plugin snapshot and its attachment copies while leaving the source chat and other snapshots unchanged.

## Export flow

The export route accepts a bounded native form request and export.js writes a versioned ZIP:

~~~text
manifest.json
sessions/001-safe-title-id/session.json
sessions/001-safe-title-id/transcript.md
~~~

session.json preserves the complete metadata and event values returned by persistence, plus archive title, workspace, timestamps, origin, tags, notes, and storage facts. transcript.md is produced with Harness's canonical message projection.

ZIP paths are sanitized and collision-safe. Batch export inspects and writes sessions sequentially, retaining at most one inspected payload. Attachment references can remain in JSON, but attachment bytes and descendant sessions are outside the version-one format.

## Import and restore flow

import/inspect accepts only version-one ZIPs produced by this plugin. The Host streams bounded compressed chunks, preflights declared entry sizes, counts actual output, and caps entry count, per-entry bytes, manifest bytes, and total expansion. Iterative JSON validation then caps depth, node count, and total Unicode code points before checking paths, versions, generator, workspace, storage descriptors, timestamps, `source.meta.id`, the event array, and cross-file consistency:

1. The browser uploads the ZIP and receives session summaries, versions, size, and warnings.
2. Existing session IDs are marked as conflicts and deselected by default.
3. Unresolved workspaces and attachment references are warnings, never invented data.
4. After confirmation, the browser submits a single-use token and selected non-conflicting IDs.
5. restore.js uses a feature-detected adapter to write sessions, metadata, and archive state.
6. Any failure rolls back staged data and never overwrites an existing session.

The confirmation token expires quickly, can be used once, and is bounded to eight retained plans and 128 MiB total per process. Confirmation-time conflict revalidation, staging, and commit all run inside the shared lifecycle queue. Import resolves a session writer by capability: a dedicated Host restore entry point when one exists, otherwise the ordinary `create` / `append` / `locate` surface — the same capability legacy-snapshot recovery writes through, so import works wherever that works. The append writer carries its own session-scoped rollback (it confirms the located directory is the session's own before creating anything), so a separate removal capability is required only for a dedicated restore entry point. Archive and metadata write capabilities are still required. The staged id does not exist yet, so a session reader that fails closed on unknown ids is the expected answer to the capability probe and never aborts the restore. Workspace attach is used only with a matching detach, otherwise the item restores ungrouped with a warning. A boundary that throws after changing state is compensated in reverse order; failed compensation is reported explicitly rather than returning false success.

## Recycle and protection-snapshot lifecycle

`trash.json` permits only `trashed`, `purge-pending`, and `degraded`. Legal transitions are `missing -> trashed`, `trashed/degraded -> purge-pending`, and removal of an existing state after a committed transaction. A `purge-pending` record cannot restore.

Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each regular recycle record names one active snapshot. A valid snapshot left without a regular recycle reference after restore or another recycle cycle automatically appears as a legacy-snapshot item in the unified Recycle Bin until it is restored as a copy or permanently deleted. Exact limits are 4 MiB manifest, 64 MiB session JSON, 1,000 attachments, 32 MiB each, and 512 MiB total. Restore validation streams attachment digests first and rereads one attachment at a time immediately before Host writes, never retaining all attachment bytes together. Snapshot publication/deletion and state-file renames sync file and parent-directory durability, with a safe fallback on Windows filesystems that do not expose directory fsync. Windows cannot atomically replace a file or remove a directory entry while another handle is open on it — an indexer or antivirus scan is enough — so replaces and recursive removals retry the transient `EPERM` / `EACCES` / `EBUSY` codes there, bounded, and only there: on POSIX the same codes are permanent conditions and retrying would only delay the same failure. Snapshot publication also treats a rename refused onto an existing directory as a conflict after probing the destination, because Windows reports that as `EPERM` rather than `EEXIST`. Path containment is tested with the platform separator and rejects an absolute answer, so a Windows `..\` escape or a different drive letter cannot read as inside the root.

Move ordering is: validate archive ownership → dispose or park a live session → capture and verify snapshot → recheck ownership → atomically commit `trashed` → invalidate caches. Ordinary move never removes the persistence artifact.

Archived sessions also expose an explicit direct permanent-delete action. It uses the same session-scoped physical-delete checks and crash-recovery bracket as Recycle Bin purge, but intentionally skips snapshot creation and recycle storage after a separate irreversible confirmation. Ordinary delete remains recoverable by default.

Restore first rejects an existing-ID conflict. With an intact original it restores archive visibility and removes only the regular recycle record without rewriting persistence; the protection snapshot then appears as a legacy-snapshot item in the unified Recycle Bin. With a missing original it completes validation and attachment-identity republishing before writing through public `create` / `append` / `saveImage` capabilities. A failure rolls back the new artifact and retains trash.

Permanent purge persists `purge-pending` before physical writes, then removes every snapshot for that source, then the original session, and finally the recycle record. The session delete is deliberately last: a failure before it leaves the original intact and the record completable, rather than a `purge-pending` record whose session is already gone and which can therefore neither restore nor complete. The snapshot sweep attributes each published snapshot by manifest identity so a snapshot that fails validation is still removed when it belongs to this session, and an unrelated unverifiable snapshot is skipped instead of aborting the sweep — corruption elsewhere in the store must never make a purge impossible. The recycle record also names its own snapshot id, covering one damaged past attribution. Workspace, metadata, snapshot, or physical-delete failures retain `purge-pending`; snapshot deletion is rescanned before success can be returned. Physical deletion additionally requires the located artifact to sit in a directory named for the session itself, so a backend layout that shares one parent between sessions can never have that parent removed. Startup recovery retries only `purge-pending`, never plain `trashed`. Legacy `pending-deletions.json` is strict read-only migration input: each still-archived ID becomes recoverable trash and is never boot-deleted merely because of the old marker.

## Browser client

client.js registers an order-30 `settings.section` plus a `shell.overlay`, and uses public Host archive services and design tokens. Workspace archive UI state stays inside the plugin-owned settings section and requires no workspace-action slot or shared client store. The page state includes:

- A frame-wide archive success notice in `shell.overlay`: during its effect lifetime the plugin wraps public `workspaces.archiveSession` and reports success only after the original succeeds. View and Undo remain available, with a three-second dismissal. No history capture is requested.
- An **Archive workspace chats** action in **Settings → Session Archive**: the chooser lists only workspaces with eligible chats and supports single selection, multiple selection, and Select all that toggles off on a second click. The bottom-right Confirm button is the only continuation. The client prepares each selection, drops workspaces that concurrently became empty, and shows one aggregate confirmation with the exact total eligible count, Archived destination, and only a nonzero live-skip count, with no visible session preview. Full success refreshes consumers and closes; any skipped or failed outcome retains the per-item result until dismissed.
- Archived sessions and workspace groups.
- Search, type/project/tag filters, and sorting.
- Tag and note editor.
- Row actions handle one chat in this order: preview, edit tags and note, permanent delete, and Unarchive; there is no single-chat export. Archive workspace menus provide Export all, Unarchive all, Delete all permanently, and Move all to Recycle Bin in that order. Recycle Bin workspace menus provide Restore all and Delete all permanently. Neither view has multi-select state, row checkboxes, or a batch toolbar.
- Archived and Recycle Bin title rows provide cross-workspace Delete all and Empty Recycle Bin actions respectively; the latter covers every recycled chat and protection snapshot. Recycle Bin row restore and permanent delete are named, tooltip-backed icon buttons.
- Four tabs: Archived, Recycle Bin, Storage & Retention, and Origins & Branches. Existing snapshots expose preview, recovery as a new archived copy, and confirmed deletion in the Recycle Bin. Restore confirmation focuses Cancel first and never places token/nonce in the render tree. Storage and relationship views retain bounded dialogs and read-only relationship projection.
- Import preview, disabled conflicts, and restore results.
- Responsive settings-page markers and sidebar refresh injection.

When `MenuAction`, `defineStore`, and `sidebar.workspaces.workspace.action` are available, one handle declared per plugin apply is shared by the workspace action, `shell.overlay`, and `settings.section`. The Host menu owner closes its menu before invoking the contributed callback and supplies `restoreFocus`; the plugin keeps that callback only in its apply closure, while components receive actions and selector hooks from the slot renderer. Contributor disposal is the plugin's responsibility: apply cleanup marks the contribution disposed synchronously and the deferred callback checks that guard before retaining focus or opening state. The Host separately owns cancellation when its row/browser unmounts. Missing optional support omits only this action and dialog.

The preview prefers Harness's publicly exported `MarkdownText`, `DisclosureRow`, and `JsonBlock`. When a public primitive is unavailable, only that content falls back to escaped plain text, native `details`/`summary`, or `pre`; the plugin never reaches into a private chat renderer. A tool result folds into an earlier call only when its `toolCallId` exactly matches the call's `callId`, consuming matches in chronological order. Unmatched results remain standalone, and errors use the semantic error token. Images are read from the protected route into Blob URLs, may load lazily before entering the viewport, and abort their read and call `URL.revokeObjectURL` when the preview closes or the image node unmounts.

The turn rail remains part of the preview: on desktop it stays to the left of the feed, jumps and follows feed scrolling, and exposes the active turn through `aria-current`; at 640px or narrower it moves above the feed and scrolls horizontally while user bubbles retain useful width. It is not replaced by a private host navigation component.

The browser never mutates files directly. After an operation, the Host response becomes the new list baseline. Closing a preview or switching to another session aborts the pending request, and a request sequence ignores late responses so a closed dialog cannot reopen and an older session cannot replace the newest preview.

## Security and failure policy

- All state-changing routes require POST and the guard header.
- Compatibility History responses and legacy-snapshot Recycle Bin items exclude workspace/snapshot/attachment paths, raw events, notes, and confirmation tokens; logs contain only IDs and stable codes.
- Workspace archive responses expose only safe workspace IDs/titles, eligible counts, session IDs/titles/timestamps in a confirmed preview, and stable per-item outcomes; workspace paths, event bodies, notes, attachment paths, and confirmation credentials stay out of logs and rendered results.
- Import limits ZIP size, entries, paths, versions, and JSON structure, rejecting traversal, duplicates, and prototype-pollution keys.
- Ordinary delete never invokes physical purge; only a committed recycle record can enter purge.
- Snapshot and recycle documents use `0600`, directories use `0700`, and snapshot files are reopened with write access before sync; publication remains temporary write, sync, atomic rename with matching durability semantics on Windows, macOS, and Linux.
- Purge removes snapshot attachment copies but does not promise immediate cleanup of identical bytes still retained by Harness's global attachment store.
- Unknown host capabilities must degrade or return a clear error; they must not be inferred.

## Compatibility and testing

The plugin adapts through Host capability detection: archive reads, attachment reads, persistence writes, physical location, and live-session lifecycle support are evaluated independently, and missing capabilities must degrade safely or return explicit errors. A legacy persistence object with native `inspect` is retained unchanged. The current Host's `list()` snapshots and `open(id, 'read')` handles are adapted into a private `list` / `listSnapshots` / `inspect` read view; every inspection reads from offset 0 and closes its handle after success or failure. The view does not invent `create`, `append`, or `locate`. Ordinary sessions remain browsable, exportable, and snapshot-capable, while session-directory accounting, restore writes, and permanent purge are unavailable through this read-only view. Without physical location, purge returns `purge-unsupported` before changing recycle state, snapshots, pending markers, or live sessions.

Snapshot and ZIP schema v1 cannot preserve `inheritedEventCount`. When a current read handle reports an inherited prefix greater than zero, the adapter returns `session-inspection-unsupported` before reading events and still closes the handle, avoiding a silently flattened branch history. Workspace archive no longer depends on snapshot capture. Recycle protection and ZIP export continue to reject unsupported inherited event data. This UI change does not widen the snapshot or restore protocol.

On Hosts with the compatible legacy writer surface, Import, legacy-snapshot recovery, and snapshot fallback when the original is missing still write through the public `create` / `append` / `locate` capability, or a dedicated restore entry point where the Host offers one; only a Host exposing neither returns `restore-unsupported` without mutation. A capability set that no shipped Host satisfies is not an acceptable guard — it makes the feature permanently dead rather than gracefully degraded. Back up the complete plugin-data directory before downgrading to a release that does not understand the unified Recycle Bin or newer snapshot state.

Coverage includes:

- export.js records, transcripts, and ZIP streaming.
- import.js bounded validation and unsafe-path rejection.
- restore.js transactional commit, rollback, and unsupported capabilities.
- metadata.js versioning, concurrency, and atomic writes.
- stats.js symlink handling, caching, and concurrency limits.
- search.js message projection, Unicode search, pagination, partial failures, and TTL/LRU caching.
- trash.js, snapshot.js, and recycle.js format validation, concurrency, recovery, rollback, crash intent, and legacy migration.
- insights.js, retention.js, retention-service.js, auto-retention.js, and lineage.js trusted accounting, policy bounds, short-lived authority, revalidation, scheduler lifecycle, and bounded graph projection.
- history.js, history-restore.js, and legacy-recycle.js legacy-snapshot preservation, unified recycle projection, cache invalidation, snapshot authorization, single-use confirmation, transaction rollback, and source immutability.
- Host routes and browser settings smoke/responsive behavior.

Run:

~~~sh
npm test
npm pack --dry-run --json
~~~
