# dsh-session-mgr

**Session Manager for the DeepSeek Harness Web UI**

Move, archive, restore, backup and delete conversations — including archived ones — across workspaces, right from the Settings page.

[**中文**](README.md) · [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) · ![dsh](https://img.shields.io/badge/dsh%20web%20plugin-0.6.7-blueviolet)

---

## Table of Contents

- [Features](#features)
- [Screenshots](#screenshots)
- [Requirements](#requirements)
- [Installation](#installation)
- [Usage](#usage)
- [HTTP API](#http-api)
- [How It Works](#how-it-works)
- [Safety](#safety)
- [Development](#development)
- [License](#license)

---

## Features

| Feature | Description |
|---|---|
| **Move** | Relocate any conversation (including archived ones) to any workspace. |
| **Archive** | Hide a conversation from the sidebar; its workspace position and order are kept. |
| **Restore** | Un-archive a conversation back to its original place. |
| **Backup / Export** | Produce a **portable archive** — a `<sessionId>.zip` / `<sessionId>.tar.gz` containing a `manifest.json`, the full session log and every **attachment** the conversation references (images and files). Transfer it to another machine and **Import** there to resume the conversation seamlessly. |
| **Import** | Install a portable package on this machine, rewriting the session `cwd` to a workspace/folder that exists here — so a conversation started on another machine continues here. |
| **Delete** | Permanently erase a conversation from disk, behind a red **double-confirm** dialog. |
| **Trilingual UI** | English / 简体中文 / 繁體中文 — follows the harness language setting; the 简体/繁體 switch lives inside the plugin page. |
| **Header quick action** | "Move to Workspace" for the currently open conversation. |

Both **per-row** and **batch** operations are supported (move / archive / restore / backup / delete selected).

### Cross-machine portability

A session belongs to a workspace through its header `cwd` — an absolute path that is machine-specific. So a plain folder copy would break on another machine (the path no longer exists). Instead:

1. On machine A: **Backup** a session → you get a portable package.
2. Copy that package to machine B.
3. On machine B: **Import** the package and pick the workspace/folder where the session should live → the import rewrites the `cwd` to B's path (the conversation content is untouched), and the session resumes seamlessly on B.

> **Cross-machine note:** a workspace is a folder, and a session is tied to it through the header's `cwd` — an absolute path that is specific to each machine. So to continue a conversation that was backed up on machine A, use **Import** on machine B to rewrite the `cwd` to a path that exists there.

> **When the package comes from a newer DSH:** it can contain event types this build has no vocabulary for. The import compares every event against the local harness' own vocabulary and adds `ignorable: true` (safe to skip) before installing — only the frames containing such an event are re-compressed, the header frame and every other frame keep their original bytes and checksum, and the event count, order and numbering stay unchanged; the UI reports which types were skipped. Without this you get "import succeeded, but opening the conversation fails with `failed to observe session … not marked ignorable`".

## Screenshots

**Session Manager page**

![Session Manager](docs/screenshots/session-manager_EN.png)

**Dialogs**

| Move to Workspace | Backup / Export | Import | Delete (double-confirm) |
|---|---|---|---|
| ![Move](docs/screenshots/move-dialog_EN.png) | ![Backup](docs/screenshots/backup_EN.png) | ![Import](docs/screenshots/import_EN.png) | ![Delete](docs/screenshots/delete-confirm_EN.png) |

## Requirements

- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) with the **web** profile (`dsh web`)
- Node.js `^22.19.0 || >=24.0.0` (the harness runtime)

## Installation

### Method 1 — from npm (recommended)

```sh
# 1. Install it from the npm registry into your web profile.
dsh plugin --profile web add dsh-session-mgr

# 2. Add the bundle to your web profile's package.json
#    (the `dsh.profile.bundles` array, alongside the other plugin entries):
#    "dsh-session-mgr"

# 3. Restart the web server for the plugin to load.
dsh web
```

`npm install dsh-session-mgr` works too, and `dsh-session-mgr@0.6.7` pins the version.

### Method 2 — from this repository

```sh
# Clone / download the repo, then replace <path> with the absolute path of the folder.
dsh plugin --profile web add "file:<path>\dsh-session-mgr"

# Then add "dsh-session-mgr" to dsh.profile.bundles and restart dsh web, as above.
```

### Method 3 — manual

Put the package into your profile's `node_modules` (e.g. `pnpm add file:...` or a symlink), then add `"dsh-session-mgr"` to the `dsh.profile.bundles` list in the profile's `package.json`, and restart.

## Usage

1. Open **Settings → Session Manager**.
2. All conversations are grouped by their current workspace; archived ones carry a badge.
3. Use the per-row buttons, or tick the checkboxes and use the batch toolbar:

   - **Move selected…** → pick a destination workspace (or type any existing folder to make it "Ungrouped").
   - **Archive selected…** → hide them from the sidebar (position kept).
   - **Restore selected…** → un-archive them back to their place.
   - **Backup selected…** → export each session as a **portable archive** (a `<sessionId>.zip` / `.tar.gz` with `manifest.json`, the full log and all attachments) to a folder you choose.
   - **Import…** → install a portable package from another machine into a chosen workspace/folder (safe to run on machine B).
   - **Delete selected…** (red) → a red warning dialog asks you to **confirm again** before anything is erased.

4. While a conversation is open, the header also shows **Move to Workspace** to relocate it in one click.
5. Use the **简体/繁體** switch in the toolbar (shown when the harness language is Chinese) to toggle the Chinese script.

## HTTP API

The host half exposes a small JSON API under `/dsh-session-mgr/*` (all `POST`):

| Endpoint | Request | Response |
|---|---|---|
| `/dsh-session-mgr/list` | `{}` | `{ workspaces, sessions, archivedSessionIds, unreadable, backupDefaultDir }` |
| `/dsh-session-mgr/move` | `{ sessionId, targetPath }` | `{ ok, sessionId, archived, from, to }` |
| `/dsh-session-mgr/archive` | `{ sessionId }` | `{ ok, archived, sessionId, archivedSessionIds }` |
| `/dsh-session-mgr/unarchive` | `{ sessionId }` | `{ ok, archived, sessionId, changed, archivedSessionIds }` |
| `/dsh-session-mgr/backup` | `{ sessionId, targetDir, format }` | `{ ok, sessionId, cwd, archived, backupPath, sizeBytes, format, manifest }` |
| `/dsh-session-mgr/import` | `{ sourcePath, targetPath }` | `{ ok, sessionId, importPath, cwd, unknownEvents, workspaceId?, workspaceTitle? }` |
| `/dsh-session-mgr/delete` | `{ sessionId }` | `{ ok, sessionId, deleted, reason?, path, sizeBytes?, cwd? }` |

`targetPath` accepts either a real directory path or a registered workspace id. `backup` (`format: "zip"` for Windows or `"targz"` for Linux) produces a **portable archive file** (`<sessionId>.zip` / `<sessionId>.tar.gz`) that `import` reads back on this machine (remapping `cwd` to `targetPath`). `unknownEvents` lists the `{ type, count }` events this import had to mark skippable because this build has no vocabulary for them; it is usually an empty array.

## How It Works

In DeepSeek Harness a **workspace is a folder**: a session belongs to a workspace through its session header `cwd`. "Moving" a session therefore:

1. moves the session artifact folder from `<sessions>/<encoded-old-cwd>/<id>` to `<sessions>/<encoded-new-cwd>/<id>`;
2. rewrites the `cwd` field of the JSONL log header (the first zstd frame);
3. updates the in-memory workspace registry so the sidebar regroups immediately — **no restart needed**.

Archive/unarchive uses the registry-global archive set; an archived session keeps its `sessionIds` slot, so restoring puts it back exactly where it was.

## Safety

- **Running** sessions cannot be moved / archived / backed up / deleted (checked via the `agents` service).
- Idle live sessions are evicted from memory before a move/delete so the next open reads the new header.
- Existing files/folders with the same name at the destination are **refused** (no silent overwrite).
- Cross-volume (EXDEV) moves fall back to copy + delete source.
- Delete requires a **double confirmation** and is irreversible — a backup is recommended.

## Development

The pure helpers in `lib/host.js` (path encoding, zstd frame scan, header rewrite, folder move) are unit-testable:

```sh
node scripts/test-i18n.mjs           # trilingual key / error-code parity (mechanical self-check)
node scripts/test-session-ops.mjs    # session-operation seam (generation fallback, cache re-key/prune)
node scripts/test-move.mjs           # synthetic-data tests
node scripts/test-real.mjs           # tests against a COPY of a real session
```
`test-real.mjs` needs a real session dir as its argument (or `DSH_REAL_SAMPLE`):
```sh
node scripts/test-real.mjs "C:\path\to\<session-id>\"
```

The live tests need a running harness (default `http://127.0.0.1:3080/dsh-session-mgr`; the **desktop app serves on a different port**, so pass its own URL as the first argument, e.g. `http://127.0.0.1:19387/dsh-session-mgr`). They create throw-away sessions under `DSH_HOME` and clean up after themselves:

```sh
node scripts/test-session-ops-live.mjs         # move / archive / unarchive / backup / delete (incl. a legacy-generation session and cache reclamation)
node scripts/test-import-live.mjs              # import: container + generation normalisation, attachments
node scripts/test-import-rollback.mjs          # a failed import must roll back and strand nothing
node scripts/test-import-security-live.mjs     # a crafted archive (zip-slip) must be refused
node scripts/test-import-compat-live.mjs       # a newer harness' unknown events gain the ignorable marker
```

After a DSH upgrade (the session format version can change) running this set is the compatibility check.

## Contributing

Found a bug or want a feature? Open an [issue](../../issues) — and please feel free to send a **pull request** directly. Both are equally valuable: a report on its own is credited in the changelog and the contributor table.

**About pull requests**: we review first and then decide whether to merge into `main` — we do not merge blindly. The way [@ron0115](https://github.com/ron0115) did it in [#3](https://github.com/mienfong/dsh-session-mgr/pull/3) is the model we are happy with:

- a focused branch that changes as little as the fix needs;
- a short description of the failure and of the reasoning behind the fix;
- tests where they are meaningful — `scripts/test-*.mjs` are plain `node` scripts (no test framework), and the live ones take the harness URL as their first argument (the desktop app serves on a different port).

Style: plain ESM, no build step, zero runtime dependencies; add and run tests for any change to `lib/host.js`.

## Contributors

Thanks to everyone who made this plugin better:

| Contributor | Contribution |
| --- | --- |
| [@ron0115](https://github.com/ron0115) | Cross-version import: marks unknown event types written by a newer DSH as `ignorable: true`, fixing "the import succeeds but the conversation will not open" ([#3](https://github.com/mienfong/dsh-session-mgr/pull/3)) |
| [@kaschey9](https://github.com/kaschey9) | Security and UI review, and five real reports: the zip-slip path traversal ([#1](https://github.com/mienfong/dsh-session-mgr/issues/1)), button labels invisible in the dark theme ([#2](https://github.com/mienfong/dsh-session-mgr/issues/2)), healthy sessions refused as `artifact-missing` when their current log generation was never materialised ([#4](https://github.com/mienfong/dsh-session-mgr/issues/4)), the sidebar losing a session's title after a move ([#5](https://github.com/mienfong/dsh-session-mgr/issues/5)), and orphaned projection-cache documents left behind forever by delete ([#6](https://github.com/mienfong/dsh-session-mgr/issues/6)) |

Reporting a bug, asking a question or sending a PR all count — see [Contributing](#contributing) above.

## Known limits

- **Packaging happens in memory.** The ZIP and tar.gz writers/readers are dependency-free pure JS, so the whole archive (attachments included) is held in memory; a 20 MB attachment measured ~0.2–0.25 s to back up and ~0.2 s to import, but an attachment of a few hundred MB costs a multiple of its size in memory. (Content that cannot be compressed no longer pays for deflate either: a 20 MB random attachment went from 0.84 s to 0.23 s, while compressible content still compresses.)
- **The session list walks every session on the machine.** `persistence.list()` itself measured ~1.8 ms per session (~20 ms for 11 here, ~0.5 s for 311). **Titles** used to be the expensive part — every real session's log is read and folded, ~60 ms each — and are now cached by the log's revision, so only sessions that changed are queried again: ~0.7 s for the first load of this machine's 11 sessions, then ~25 ms. The plugin's own `unreadable` scan measured as negligible.
- **`unreadable` covers session directories with no readable header** (a truncated or corrupt log, a missing log, an interrupted import). Other forms of corruption are the harness' own load-time checks.

## License

[MIT](LICENSE)
