opencode-session-port 0.1.3 → 0.1.5

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.
package/README.md CHANGED
@@ -4,12 +4,12 @@
4
4
 
5
5
  SessionPort is a local plugin for the OpenCode TUI. It captures the sessions of your current project into a single password-encrypted artifact and restores them in another checkout, worktree, or machine. Artifacts travel with Git or any other channel you choose — SessionPort never runs Git commands itself.
6
6
 
7
- > **Status: experimental pre-release (0.1.3).** SessionPort completed one bounded synthetic checkpoint/restore round trip on Windows with OpenCode v2.0.22. That test used test-only password and project-selector seams and staged source, not the npm tarball. On one real Windows OpenCode v2.0.22 host, a bounded diagnostic observed both commands registered (`commands=yes`) but hidden from the command palette and slash list until the keymap layer opted into global mode; 0.1.2 applies that global-mode registration fix, with focused command-registration tests and the TUI build passing. 0.1.3 additionally enables the commands on macOS and Linux through an explicit platform allowlist (`win32`/`darwin`/`linux`); macOS and Linux have NOT been host-tested, and cross-OS artifact interchange is UNVERIFIED. End-to-end execution from the packed package has not been host-tested. Real modal interactions and loading the packed package in OpenCode have not been verified. The checkpoint format may change; no compatibility, security, or platform-support guarantee is made.
7
+ > **Status: experimental pre-release (0.1.5).** SessionPort completed one bounded synthetic checkpoint/restore round trip on Windows with OpenCode v2.0.22. That test used test-only password and project-selector seams and staged source, not the npm tarball. On one real Windows OpenCode v2.0.22 host, a bounded diagnostic observed both commands registered (`commands=yes`) but hidden from the command palette and slash list until the keymap layer opted into global mode; 0.1.2 applies that global-mode registration fix, with focused command-registration tests and the TUI build passing. 0.1.3 additionally enables the commands on macOS and Linux through an explicit platform allowlist (`win32`/`darwin`/`linux`). 0.1.2 and 0.1.3 are published to npm (0.1.3 latest, per a read-only registry check); 0.1.4 and 0.1.5 are local candidates (0.1.5 current). 0.1.4 was a diagnostics/copy patch: a failed checkpoint collection appends a fixed sanitized reason and the known machine code (`Reason: <fixed reason> (<code>).`) for the current collection codes; unknown codes keep the generic text and raw host/exception text is never shown. 0.1.5 adds a bare `/restore` default that uses the newest checkpoint in the current project's `.sessionport/` directory (newest file modification time; no fallback to an older artifact on a corrupt file, wrong password, or read failure) and extends the same fixed-reason diagnostics to known restore plan/collection error codes. Collector behavior is unchanged, and no root cause is established for an earlier Windows collection failure; the concrete code behind an earlier restore rejection is likewise still pending a user runtime observation, and no planner correctness fix is claimed. macOS has one user-reported successful checkpoint only; Linux is untested; restore and cross-OS artifact interchange remain unestablished. End-to-end execution from the packed package has not been host-tested. Real modal interactions and loading the packed package in OpenCode have not been verified. The checkpoint format may change; no compatibility, security, or platform-support guarantee is made.
8
8
 
9
9
  ## Commands
10
10
 
11
11
  - **`/checkpoint [artifact-path]`** — collects the current project's sessions as returned by the host (child/subagent and archived sessions included when the host returns them) and writes one encrypted artifact. It fails cleanly, with no artifact, when the set is empty, a session cannot be captured completely, or the output file already exists.
12
- - **`/restore <artifact-path>`** — decrypts and validates the artifact before session imports, then restores it into an existing project you select. Restore does not overwrite existing sessions: exact destination matches are skipped, conflicting IDs are remapped deterministically, and parents are imported before children.
12
+ - **`/restore [artifact-path]`** — decrypts and validates the artifact before session imports, then restores it into an existing project you select. Without a path, `/restore` uses the newest checkpoint in the current project's `.sessionport/` directory (newest file modification time; an exact tie is broken deterministically by filename). Restore does not overwrite existing sessions: exact destination matches are skipped, conflicting IDs are remapped deterministically, and parents are imported before children.
13
13
 
14
14
  Both commands are registered by the plugin as `sessionport.checkpoint` and `sessionport.restore`, and appear in the slash-command list and the command palette.
15
15
 
@@ -24,17 +24,17 @@ Add the plugin to the `plugins` array in your global CLI-only config (`cli.json`
24
24
 
25
25
  ```json
26
26
  {
27
- "plugins": ["opencode-session-port@0.1.3"]
27
+ "plugins": ["opencode-session-port@0.1.5"]
28
28
  }
29
29
  ```
30
30
 
31
31
  Or install the package plugin with the OpenCode CLI:
32
32
 
33
33
  ```sh
34
- opencode plugin add opencode-session-port@0.1.3
34
+ opencode plugin add opencode-session-port@0.1.5
35
35
  ```
36
36
 
37
- `cli.json` is the documented OpenCode v2 config for CLI-only plugins: they run locally in the terminal and remain active when the CLI connects to a remote server. No published tarball exists yet, so no published artifact has been loaded by OpenCode. The 0.1.1 tarball passed npm consumer checks, but a direct Bun package-manager install timed out in the verification environment, so that install path remains unverified. The 0.1.2 tarball passed manifest/allowlist and packed-entry checks only. The 0.1.3 metadata/platform-allowlist update has not been package-verified, published, or loaded by OpenCode.
37
+ `cli.json` is the documented OpenCode v2 config for CLI-only plugins: they run locally in the terminal and remain active when the CLI connects to a remote server. The npm registry shows 0.1.2 and 0.1.3 published (0.1.3 latest; read-only registry check); no published artifact has been loaded by OpenCode here, and no local package-verification record exists for the published 0.1.3 tarball. The 0.1.1 tarball passed npm consumer checks, but a direct Bun package-manager install timed out in the verification environment, so that install path remains unverified. The 0.1.2 tarball passed manifest/allowlist and packed-entry checks only. The 0.1.4 and 0.1.5 candidate tarballs passed the same local manifest/allowlist and packed-entry checks (0.1.5 including the bare-restore latest-checkpoint resolver and the restore reason-copy allowlists); neither is published by this repository lane.
38
38
 
39
39
  ## Usage
40
40
 
@@ -51,9 +51,11 @@ Checkpoint prompts for a password twice in a masked input (paste is allowed); bo
51
51
  ### Restore
52
52
 
53
53
  ```text
54
- /restore <artifact-path>
54
+ /restore [artifact-path]
55
55
  ```
56
56
 
57
+ Without an argument, `/restore` picks the newest checkpoint in the current project's `.sessionport/` directory (newest file modification time wins; an exact tie is broken deterministically by filename, descending). If the project has no active directory or no checkpoint is found, nothing is changed. Restore never falls back to an older checkpoint: a corrupt file, wrong password, or read failure stops the command with an error instead of trying another artifact.
58
+
57
59
  Restore first asks which existing project to import into, then prompts for the password (up to three attempts). The artifact is fully checked before the first session import. Project resolution may still update bounded host project metadata through OpenCode's public `location.get` API. Imports run parents-first, one session at a time: independent branches continue after a failure, descendants of a failed parent are blocked, and outcomes are reported per session. There is no atomic rollback. A later manual rerun may skip exact matching records, but exact retry behavior on a real host has not been established. Imported sessions are not opened or navigated to automatically.
58
60
 
59
61
  ### Moving artifacts
package/dist/tui.js CHANGED
@@ -5710,7 +5710,7 @@ var init_experimental_profile = __esm(() => {
5710
5710
  });
5711
5711
 
5712
5712
  // src/tui/artifacts/files.ts
5713
- import { lstat, mkdir, open, unlink } from "fs/promises";
5713
+ import { lstat, mkdir, open, readdir, unlink } from "fs/promises";
5714
5714
  import * as path from "path";
5715
5715
  async function resolveCheckpointOutputPath(options) {
5716
5716
  const input = options;
@@ -5761,6 +5761,51 @@ async function assertPathDoesNotExist(candidate) {
5761
5761
  function isErrnoCode(value, code) {
5762
5762
  return typeof value === "object" && value !== null && value.code === code;
5763
5763
  }
5764
+ async function resolveLatestCheckpointArtifact(options) {
5765
+ const input = options;
5766
+ if (typeof input !== "object" || input === null) {
5767
+ throw new ArtifactFileError("invalid-request", "resolveLatestCheckpointArtifact options must be an object");
5768
+ }
5769
+ const projectDirectory = requireNonEmptyString3(input.projectDirectory, "projectDirectory");
5770
+ const directory = path.resolve(projectDirectory, CHECKPOINT_DIRECTORY_NAME);
5771
+ let directoryStats;
5772
+ try {
5773
+ directoryStats = await lstat(directory);
5774
+ } catch (error) {
5775
+ if (isErrnoCode(error, "ENOENT"))
5776
+ return;
5777
+ throw error;
5778
+ }
5779
+ if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
5780
+ throw new ArtifactFileError("invalid-request", "The project's .sessionport checkpoint location is not a regular directory; refusing to scan it");
5781
+ }
5782
+ let bestName;
5783
+ let bestMtimeMs = Number.NEGATIVE_INFINITY;
5784
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
5785
+ if (!entry.name.endsWith(CHECKPOINT_FILENAME_EXTENSION))
5786
+ continue;
5787
+ if (entry.isDirectory() || entry.isSymbolicLink())
5788
+ continue;
5789
+ let candidateStats;
5790
+ try {
5791
+ candidateStats = await lstat(path.join(directory, entry.name));
5792
+ } catch (error) {
5793
+ if (isErrnoCode(error, "ENOENT"))
5794
+ continue;
5795
+ throw error;
5796
+ }
5797
+ if (!candidateStats.isFile())
5798
+ continue;
5799
+ const mtimeMs = candidateStats.mtimeMs;
5800
+ if (!Number.isFinite(mtimeMs))
5801
+ continue;
5802
+ if (bestName === undefined || mtimeMs > bestMtimeMs || mtimeMs === bestMtimeMs && entry.name > bestName) {
5803
+ bestName = entry.name;
5804
+ bestMtimeMs = mtimeMs;
5805
+ }
5806
+ }
5807
+ return bestName === undefined ? undefined : path.join(directory, bestName);
5808
+ }
5764
5809
  async function writeEncryptedArtifact(targetPath, source) {
5765
5810
  const pathString = requireNonEmptyString3(targetPath, "path");
5766
5811
  const handle = await open(pathString, "wx");
@@ -5866,7 +5911,7 @@ function reportCheckpointFailure(error) {
5866
5911
  case "capability":
5867
5912
  return checkpointFailure("This OpenCode host does not expose the required session-listing capability; no artifact was written.");
5868
5913
  case "collection":
5869
- return checkpointFailure("Collecting the project's sessions failed; no artifact was written.");
5914
+ return checkpointFailure(checkpointCollectionFailureMessage(error));
5870
5915
  case "path-exists":
5871
5916
  return checkpointFailure("An artifact already exists at that path; nothing was overwritten.");
5872
5917
  case "artifact-path":
@@ -5885,10 +5930,36 @@ function reportCheckpointFailure(error) {
5885
5930
  function checkpointFailure(message) {
5886
5931
  return { title: "Checkpoint failed", message, variant: "error" };
5887
5932
  }
5933
+ function checkpointCollectionFailureMessage(error) {
5934
+ return withKnownCollectionReason(error, CHECKPOINT_COLLECTION_FAILURE);
5935
+ }
5936
+ function withKnownCollectionReason(error, base) {
5937
+ if (!(error instanceof SessionCollectionError))
5938
+ return base;
5939
+ const code = error.code;
5940
+ if (!Object.hasOwn(COLLECTION_FAILURE_REASONS, code))
5941
+ return base;
5942
+ const reason = COLLECTION_FAILURE_REASONS[code];
5943
+ return `${base} Reason: ${reason} (${code}).`;
5944
+ }
5888
5945
  function reportRestoreUsage() {
5889
5946
  return {
5890
- title: "Restore needs an artifact path",
5891
- message: "Usage: /restore <path-to-checkpoint>. No sessions were changed.",
5947
+ title: "Restore usage",
5948
+ message: "Usage: /restore [artifact-path]. Without a path, the newest checkpoint in the current project's .sessionport directory is used. No sessions were changed.",
5949
+ variant: "warning"
5950
+ };
5951
+ }
5952
+ function reportRestoreNoActiveProject() {
5953
+ return {
5954
+ title: "Restore unavailable",
5955
+ message: "The active project directory is unavailable, so the latest checkpoint could not be found; no sessions were changed.",
5956
+ variant: "warning"
5957
+ };
5958
+ }
5959
+ function reportRestoreNoCheckpointFound() {
5960
+ return {
5961
+ title: "No checkpoint found",
5962
+ message: "No checkpoint artifact was found in the current project's .sessionport directory; no sessions were changed. Create one with /checkpoint or pass an artifact path to /restore.",
5892
5963
  variant: "warning"
5893
5964
  };
5894
5965
  }
@@ -5913,6 +5984,18 @@ function reportRestoreAuthenticationFailed() {
5913
5984
  variant: "error"
5914
5985
  };
5915
5986
  }
5987
+ function restoreCollectionFailureMessage(error) {
5988
+ return withKnownCollectionReason(error, RESTORE_COLLECTION_FAILURE);
5989
+ }
5990
+ function restorePlanFailureMessage(error) {
5991
+ if (!(error instanceof RestorePlanError))
5992
+ return RESTORE_PLAN_FAILURE;
5993
+ const code = error.code;
5994
+ if (!Object.hasOwn(RESTORE_PLAN_REASONS, code))
5995
+ return RESTORE_PLAN_FAILURE;
5996
+ const reason = RESTORE_PLAN_REASONS[code];
5997
+ return `${RESTORE_PLAN_FAILURE} Reason: ${reason} (${code}).`;
5998
+ }
5916
5999
  function reportRestoreFailure(error, executionStarted) {
5917
6000
  if (executionStarted) {
5918
6001
  return {
@@ -5932,9 +6015,9 @@ function reportRestoreFailure(error, executionStarted) {
5932
6015
  case "destinations":
5933
6016
  return restoreFailure("The host project list could not be read; no sessions were changed.");
5934
6017
  case "collection":
5935
- return restoreFailure("The destination project's sessions could not be read; no sessions were changed.");
6018
+ return restoreFailure(restoreCollectionFailureMessage(error));
5936
6019
  case "plan":
5937
- return restoreFailure("The checkpoint does not match this destination safely; no sessions were changed.");
6020
+ return restoreFailure(restorePlanFailureMessage(error));
5938
6021
  case "artifact-path":
5939
6022
  return restoreFailure("The artifact path is not usable; no sessions were changed.");
5940
6023
  case "path-exists":
@@ -6039,6 +6122,7 @@ function classifyFailure(error) {
6039
6122
  }
6040
6123
  return "unknown";
6041
6124
  }
6125
+ var CHECKPOINT_COLLECTION_FAILURE = "Collecting the project's sessions failed; no artifact was written.", COLLECTION_FAILURE_REASONS, RESTORE_COLLECTION_FAILURE = "The destination project's sessions could not be read; no sessions were changed.", RESTORE_PLAN_FAILURE = "The checkpoint does not match this destination safely; no sessions were changed.", RESTORE_PLAN_REASONS;
6042
6126
  var init_reports = __esm(() => {
6043
6127
  init_files();
6044
6128
  init_adapters();
@@ -6047,6 +6131,39 @@ var init_reports = __esm(() => {
6047
6131
  init_planner();
6048
6132
  init_sessions();
6049
6133
  init_orchestration();
6134
+ COLLECTION_FAILURE_REASONS = {
6135
+ "invalid-request": "the session collection request was invalid",
6136
+ "invalid-policy": "the session collection policy was invalid",
6137
+ "session-list-failed": "the host session list could not be read",
6138
+ "message-list-failed": "a session's message history could not be read",
6139
+ "malformed-session-page": "the host returned a malformed session list page",
6140
+ "malformed-message-page": "the host returned a malformed message list page",
6141
+ "malformed-session-record": "the host returned a malformed session record",
6142
+ "malformed-message-record": "the host returned a malformed message record",
6143
+ "session-project-mismatch": "a listed session did not belong to the requested project",
6144
+ "duplicate-session-id": "the host returned a session more than once",
6145
+ "duplicate-message-id": "the host returned a message more than once",
6146
+ "repeated-session-cursor": "the host repeated a session list cursor",
6147
+ "repeated-message-cursor": "the host repeated a message list cursor",
6148
+ "session-limit-exceeded": "the session count exceeded the configured limit",
6149
+ "message-limit-exceeded": "a session's message count exceeded the configured limit",
6150
+ "total-message-limit-exceeded": "the total message count exceeded the configured limit",
6151
+ "unsettled-message": "the project history contains an unfinished message"
6152
+ };
6153
+ RESTORE_PLAN_REASONS = {
6154
+ "invalid-input": "the restore planning request was invalid",
6155
+ "malformed-payload": "the checkpoint payload was malformed",
6156
+ "malformed-destination": "the destination session history could not be read",
6157
+ "divergent-artifact-session": "the checkpoint repeats a session with different content",
6158
+ "duplicate-artifact-message-id": "the checkpoint repeats a message ID across sessions",
6159
+ "ambiguous-destination": "the destination repeats a session or message ID",
6160
+ "dangling-session-reference": "a session reference does not resolve in the destination or plan",
6161
+ "dangling-message-reference": "a message reference does not resolve in the destination",
6162
+ "session-id-collision": "a planned session ID collides with existing session history",
6163
+ "message-id-collision": "a planned message ID collides with existing session history",
6164
+ "dependency-cycle": "session parent/reference relationships contain a cycle",
6165
+ "derivation-failure": "a remapped session or message ID could not be derived"
6166
+ };
6050
6167
  });
6051
6168
 
6052
6169
  // src/tui/wiring/composition.ts
@@ -6059,6 +6176,7 @@ function createSessionPortHandlers(dependencies) {
6059
6176
  requireCallable(dependencies.showRestoreReport, "showRestoreReport");
6060
6177
  requireCallable(dependencies.requestPassword, "requestPassword");
6061
6178
  requireCallable(dependencies.resolveCheckpointOutputPath, "resolveCheckpointOutputPath");
6179
+ requireCallable(dependencies.resolveLatestCheckpointArtifact, "resolveLatestCheckpointArtifact");
6062
6180
  requireCallable(dependencies.writeEncryptedArtifact, "writeEncryptedArtifact");
6063
6181
  requireCallable(dependencies.readEncryptedArtifact, "readEncryptedArtifact");
6064
6182
  if (!isRecordValue(dependencies.cryptoFactory) || typeof dependencies.cryptoFactory.create !== "function") {
@@ -6105,11 +6223,7 @@ async function runCheckpointCommand(dependencies, rawInput) {
6105
6223
  }
6106
6224
  }
6107
6225
  async function runRestoreCommand(dependencies, rawInput) {
6108
- const artifactPath = parseRestoreArtifactArgument(rawInput);
6109
- if (artifactPath === undefined) {
6110
- dependencies.notify(reportRestoreUsage());
6111
- return;
6112
- }
6226
+ const explicitArtifactPath = parseRestoreArtifactArgument(rawInput);
6113
6227
  const capability = probeSessionImportCapability(dependencies.client);
6114
6228
  if (!capability.available) {
6115
6229
  dependencies.notify(reportRestoreCapabilityUnavailable());
@@ -6117,6 +6231,22 @@ async function runRestoreCommand(dependencies, rawInput) {
6117
6231
  }
6118
6232
  let executionStarted = false;
6119
6233
  try {
6234
+ let artifactPath;
6235
+ if (explicitArtifactPath !== undefined) {
6236
+ artifactPath = explicitArtifactPath;
6237
+ } else {
6238
+ const activeDirectory = dependencies.getActiveDirectory();
6239
+ if (typeof activeDirectory !== "string" || activeDirectory.trim().length === 0) {
6240
+ dependencies.notify(reportRestoreNoActiveProject());
6241
+ return;
6242
+ }
6243
+ const latest = await dependencies.resolveLatestCheckpointArtifact({ projectDirectory: activeDirectory });
6244
+ if (latest === undefined) {
6245
+ dependencies.notify(reportRestoreNoCheckpointFound());
6246
+ return;
6247
+ }
6248
+ artifactPath = latest;
6249
+ }
6120
6250
  const destinations = await createProjectDestinationsAdapter(dependencies.client).listDestinations();
6121
6251
  const selected = await selectRestoreProject(dependencies.dialog, destinations);
6122
6252
  if (selected === undefined) {
@@ -10214,6 +10344,7 @@ async function installSessionPortWiring(context, options = {}) {
10214
10344
  cryptoFactory,
10215
10345
  idDeriver,
10216
10346
  resolveCheckpointOutputPath,
10347
+ resolveLatestCheckpointArtifact,
10217
10348
  writeEncryptedArtifact,
10218
10349
  readEncryptedArtifact
10219
10350
  });
@@ -10239,6 +10370,8 @@ var init_install = __esm(() => {
10239
10370
  var exports_wiring = {};
10240
10371
  __export(exports_wiring, {
10241
10372
  reportRestoreUsage: () => reportRestoreUsage,
10373
+ reportRestoreNoCheckpointFound: () => reportRestoreNoCheckpointFound,
10374
+ reportRestoreNoActiveProject: () => reportRestoreNoActiveProject,
10242
10375
  reportRestoreFailure: () => reportRestoreFailure,
10243
10376
  reportRestoreExecution: () => reportRestoreExecution,
10244
10377
  reportRestoreCapabilityUnavailable: () => reportRestoreCapabilityUnavailable,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-port",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "SessionPort: experimental OpenCode TUI plugin for password-encrypted session checkpoint and restore.",
@@ -40,10 +40,16 @@
40
40
  * `:` from the UTC timestamp, no reserved device name), and drive-relative
41
41
  * explicit paths (`C:foo`) fail closed instead of silently anchoring to the
42
42
  * per-drive current directory.
43
+ * - `resolveLatestCheckpointArtifact` implements the bare `/restore` default:
44
+ * a non-recursive, content-free scan of `<projectDirectory>/.sessionport/`
45
+ * (newest `mtimeMs` wins; an exact tie is broken by filename, descending
46
+ * code-unit order). It never creates, opens, or reads a file, excludes
47
+ * symlink/swapped entries, and refuses a non-regular `.sessionport` entry
48
+ * instead of traversing it.
43
49
  */
44
50
 
45
51
  /// <reference types="node" />
46
- import { lstat, mkdir, open, unlink } from "node:fs/promises";
52
+ import { lstat, mkdir, open, readdir, unlink } from "node:fs/promises";
47
53
  import * as path from "node:path";
48
54
  import type { ByteSource } from "../../core/contracts/index.ts";
49
55
 
@@ -187,6 +193,93 @@ function isErrnoCode(value: unknown, code: string): boolean {
187
193
  return typeof value === "object" && value !== null && (value as { readonly code?: unknown }).code === code;
188
194
  }
189
195
 
196
+ /* ------------------------------------------------------------------------- */
197
+ /* Latest-checkpoint discovery (bare `/restore`) */
198
+ /* ------------------------------------------------------------------------- */
199
+
200
+ /** Options for `resolveLatestCheckpointArtifact`. */
201
+ export interface ResolveLatestCheckpointArtifactOptions {
202
+ /** Current project directory; the default checkpoint directory sits beneath it. */
203
+ readonly projectDirectory: string;
204
+ }
205
+
206
+ /**
207
+ * Resolves the newest default checkpoint artifact under
208
+ * `<projectDirectory>/.sessionport/`, or `undefined` when that directory is
209
+ * missing or contains no candidate. Selection is non-recursive and
210
+ * content-free: only entries whose name ends in the exact `.sessionport`
211
+ * suffix are considered (no prefix required), a candidate must be a regular
212
+ * file by `lstat` at selection time (symlinks and entries swapped after
213
+ * `readdir` are excluded), and only finite modification times qualify. The
214
+ * newest `mtimeMs` wins; an exact tie is broken by filename, descending
215
+ * UTF-16 code-unit order (deterministic, never locale-dependent). Nothing is
216
+ * created, opened, or read.
217
+ *
218
+ * Fail-closed behavior: a `.sessionport` entry that is not a regular
219
+ * directory (symlink/junction/file) is refused, not traversed. A missing
220
+ * directory, an empty directory, or a scan with no candidate resolves
221
+ * `undefined`; every other filesystem error (for example permissions)
222
+ * propagates unchanged to the caller's sanitized failure reporting. A
223
+ * candidate that disappears during the scan (ENOENT) is skipped; unrelated
224
+ * errors are never suppressed. This is a best-effort local discovery helper:
225
+ * it does not promise race-freedom against a malicious filesystem writer
226
+ * beyond the `lstat` re-check and the existing read safeguards.
227
+ */
228
+ export async function resolveLatestCheckpointArtifact(
229
+ options: ResolveLatestCheckpointArtifactOptions,
230
+ ): Promise<string | undefined> {
231
+ const input: unknown = options;
232
+ if (typeof input !== "object" || input === null) {
233
+ throw new ArtifactFileError("invalid-request", "resolveLatestCheckpointArtifact options must be an object");
234
+ }
235
+ const projectDirectory = requireNonEmptyString(
236
+ (input as { readonly projectDirectory?: unknown }).projectDirectory,
237
+ "projectDirectory",
238
+ );
239
+
240
+ const directory = path.resolve(projectDirectory, CHECKPOINT_DIRECTORY_NAME);
241
+ let directoryStats;
242
+ try {
243
+ // lstat: a symlinked/junction directory is never followed or traversed.
244
+ directoryStats = await lstat(directory);
245
+ } catch (error) {
246
+ if (isErrnoCode(error, "ENOENT")) return undefined;
247
+ throw error;
248
+ }
249
+ if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
250
+ throw new ArtifactFileError(
251
+ "invalid-request",
252
+ "The project's .sessionport checkpoint location is not a regular directory; refusing to scan it",
253
+ );
254
+ }
255
+
256
+ let bestName: string | undefined;
257
+ let bestMtimeMs = Number.NEGATIVE_INFINITY;
258
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
259
+ if (!entry.name.endsWith(CHECKPOINT_FILENAME_EXTENSION)) continue;
260
+ // Dirent-level exclusion first; the lstat below is authoritative for
261
+ // the rest (including entries whose dirent type is unknown).
262
+ if (entry.isDirectory() || entry.isSymbolicLink()) continue;
263
+ let candidateStats;
264
+ try {
265
+ // Re-check after readdir: the entry may have been swapped for a
266
+ // symlink or a different object between the two calls.
267
+ candidateStats = await lstat(path.join(directory, entry.name));
268
+ } catch (error) {
269
+ if (isErrnoCode(error, "ENOENT")) continue;
270
+ throw error;
271
+ }
272
+ if (!candidateStats.isFile()) continue;
273
+ const mtimeMs = candidateStats.mtimeMs;
274
+ if (!Number.isFinite(mtimeMs)) continue;
275
+ if (bestName === undefined || mtimeMs > bestMtimeMs || (mtimeMs === bestMtimeMs && entry.name > bestName)) {
276
+ bestName = entry.name;
277
+ bestMtimeMs = mtimeMs;
278
+ }
279
+ }
280
+ return bestName === undefined ? undefined : path.join(directory, bestName);
281
+ }
282
+
190
283
  /* ------------------------------------------------------------------------- */
191
284
  /* Exclusive write / lazy read */
192
285
  /* ------------------------------------------------------------------------- */
@@ -12,10 +12,16 @@
12
12
  * resolution, seal, exclusive encrypted write;
13
13
  * 3. report path/size/session-count facts or a sanitized failure.
14
14
  *
15
- * `/restore <artifact-path>`
16
- * 1. require the artifact path argument (nothing runs without it);
17
- * 2. probe `session.import` capability FIRST — before any artifact read,
18
- * decrypt, or import (O-12);
15
+ * `/restore [artifact-path]`
16
+ * 1. probe `session.import` capability FIRST — before any filesystem
17
+ * discovery, artifact read, decrypt, or import (O-12, both forms);
18
+ * 2. when no artifact path is given (bare `/restore`), read the active
19
+ * project directory, fail closed with fixed copy when it is unavailable,
20
+ * and resolve the newest checkpoint in that project's `.sessionport/`
21
+ * directory through the injected resolver; no latest file reports a
22
+ * fixed no-checkpoint copy. An explicit path bypasses this lookup (and
23
+ * the active-directory read) entirely. No active `location.get` and no
24
+ * automatic destination selection are used for either form;
19
25
  * 3. enumerate existing host projects via `project.list`, let the user select
20
26
  * with `selectRestoreProject`;
21
27
  * 4. call `location.get` ONLY for the selected candidate and require an exact
@@ -33,6 +39,9 @@
33
39
  * empty result keeps the truthful non-success notification, and a
34
40
  * dialog-display failure falls back to bounded aggregate-only copy.
35
41
  *
42
+ * No fallback artifact: a corrupt file, wrong password, or read failure stops
43
+ * the command; an older checkpoint is never silently substituted.
44
+ *
36
45
  * Hard boundaries: no shell/Git, no LLM/model call, no historical tool replay,
37
46
  * no direct database access, no `project.update`; passwords stay masked and are
38
47
  * owned/zeroed by the orchestration helpers; only already-sealed ciphertext is
@@ -47,7 +56,7 @@
47
56
  import type { ByteSource, CheckpointCryptoFactory } from "../../core/contracts/index.ts";
48
57
  import { collectCheckpointPayload } from "../../core/sessions/index.ts";
49
58
  import type { RemappedIdDeriver } from "../../core/restore/index.ts";
50
- import type { ResolveCheckpointOutputPathOptions } from "../artifacts/files.ts";
59
+ import type { ResolveCheckpointOutputPathOptions, ResolveLatestCheckpointArtifactOptions } from "../artifacts/files.ts";
51
60
  import { createLocationResolver, LocationResolutionError } from "../adapters/location-resolver.ts";
52
61
  import { createProjectDestinationsAdapter } from "../adapters/project-destinations.ts";
53
62
  import { createSessionIdInventory } from "../adapters/session-id-inventory.ts";
@@ -78,7 +87,8 @@ import {
78
87
  reportRestoreExecution,
79
88
  reportRestoreExecutionFallback,
80
89
  reportRestoreFailure,
81
- reportRestoreUsage,
90
+ reportRestoreNoActiveProject,
91
+ reportRestoreNoCheckpointFound,
82
92
  type SessionPortNotification,
83
93
  } from "./reports.ts";
84
94
 
@@ -118,6 +128,15 @@ export interface SessionPortHandlerDependencies {
118
128
  readonly idDeriver: RemappedIdDeriver;
119
129
  /** P-15 artifact output-path resolver. */
120
130
  readonly resolveCheckpointOutputPath: (options: ResolveCheckpointOutputPathOptions) => Promise<string>;
131
+ /**
132
+ * Bare `/restore` latest-artifact resolver: newest `.sessionport` artifact
133
+ * under the project's default `.sessionport/` directory, or `undefined`
134
+ * when none exists. Production uses the filesystem resolver; tests inject a
135
+ * fake so no real discovery happens.
136
+ */
137
+ readonly resolveLatestCheckpointArtifact: (
138
+ options: ResolveLatestCheckpointArtifactOptions,
139
+ ) => Promise<string | undefined>;
121
140
  /** Exclusive encrypted-artifact writer (ciphertext only). */
122
141
  readonly writeEncryptedArtifact: (targetPath: string, source: ByteSource) => Promise<number>;
123
142
  /** Lazy encrypted-artifact reader (no IO until iterated). */
@@ -142,6 +161,7 @@ export function createSessionPortHandlers(dependencies: SessionPortHandlerDepend
142
161
  requireCallable(dependencies.showRestoreReport, "showRestoreReport");
143
162
  requireCallable(dependencies.requestPassword, "requestPassword");
144
163
  requireCallable(dependencies.resolveCheckpointOutputPath, "resolveCheckpointOutputPath");
164
+ requireCallable(dependencies.resolveLatestCheckpointArtifact, "resolveLatestCheckpointArtifact");
145
165
  requireCallable(dependencies.writeEncryptedArtifact, "writeEncryptedArtifact");
146
166
  requireCallable(dependencies.readEncryptedArtifact, "readEncryptedArtifact");
147
167
  if (!isRecordValue(dependencies.cryptoFactory) || typeof dependencies.cryptoFactory.create !== "function") {
@@ -209,14 +229,12 @@ async function runRestoreCommand(
209
229
  dependencies: SessionPortHandlerDependencies,
210
230
  rawInput: string | undefined,
211
231
  ): Promise<void> {
212
- const artifactPath = parseRestoreArtifactArgument(rawInput);
213
- if (artifactPath === undefined) {
214
- dependencies.notify(reportRestoreUsage());
215
- return;
216
- }
232
+ // `undefined` means no explicit path: bare `/restore` uses the newest
233
+ // checkpoint in the active project's `.sessionport/` directory.
234
+ const explicitArtifactPath = parseRestoreArtifactArgument(rawInput);
217
235
 
218
- // O-12: capability absence is the first check — before any file read,
219
- // decrypt, destination enumeration, or import.
236
+ // O-12: capability absence is the first check — before any filesystem
237
+ // discovery, file read, decrypt, destination enumeration, or import.
220
238
  const capability = probeSessionImportCapability(dependencies.client);
221
239
  if (!capability.available) {
222
240
  dependencies.notify(reportRestoreCapabilityUnavailable());
@@ -225,6 +243,24 @@ async function runRestoreCommand(
225
243
 
226
244
  let executionStarted = false;
227
245
  try {
246
+ let artifactPath: string;
247
+ if (explicitArtifactPath !== undefined) {
248
+ // Explicit paths bypass the active-project lookup entirely.
249
+ artifactPath = explicitArtifactPath;
250
+ } else {
251
+ const activeDirectory = dependencies.getActiveDirectory();
252
+ if (typeof activeDirectory !== "string" || activeDirectory.trim().length === 0) {
253
+ dependencies.notify(reportRestoreNoActiveProject());
254
+ return;
255
+ }
256
+ const latest = await dependencies.resolveLatestCheckpointArtifact({ projectDirectory: activeDirectory });
257
+ if (latest === undefined) {
258
+ dependencies.notify(reportRestoreNoCheckpointFound());
259
+ return;
260
+ }
261
+ artifactPath = latest;
262
+ }
263
+
228
264
  // Candidate enumeration and user selection happen before any location.get
229
265
  // or artifact access; a canceled selection changes nothing.
230
266
  const destinations = await createProjectDestinationsAdapter(dependencies.client).listDestinations();
@@ -342,10 +378,11 @@ export function parseCheckpointOutputArgument(rawInput: string | undefined): str
342
378
  }
343
379
 
344
380
  /**
345
- * `/restore` requires one raw argument: the artifact path. `undefined` or an
346
- * all-whitespace argument yields `undefined` so the caller reports usage
347
- * without touching the host; any other argument is trimmed of surrounding
348
- * whitespace and used verbatim.
381
+ * `/restore` accepts one optional raw argument: the artifact path. `undefined`
382
+ * or an all-whitespace argument means the bare form (newest checkpoint in the
383
+ * active project's `.sessionport/` directory); any other argument is trimmed
384
+ * of surrounding whitespace and used verbatim (no flag parsing, no shell
385
+ * expansion, no quote handling).
349
386
  */
350
387
  export function parseRestoreArtifactArgument(rawInput: string | undefined): string | undefined {
351
388
  if (rawInput === undefined) return undefined;
@@ -47,6 +47,8 @@ export {
47
47
  reportRestoreCapabilityUnavailable,
48
48
  reportRestoreExecution,
49
49
  reportRestoreFailure,
50
+ reportRestoreNoActiveProject,
51
+ reportRestoreNoCheckpointFound,
50
52
  reportRestoreUsage,
51
53
  } from "./reports.ts";
52
54
  export type { SessionPortNotification, SessionPortNotificationVariant } from "./reports.ts";
@@ -41,7 +41,7 @@ import type { Plugin } from "@opencode/plugin/tui";
41
41
  import type { CheckpointCryptoFactory } from "../../core/contracts/index.ts";
42
42
  import { createCheckpointCryptoFactory } from "../../core/crypto/index.ts";
43
43
  import { createRemappedIdDeriver, type RemappedIdDeriver } from "../../core/restore/index.ts";
44
- import { readEncryptedArtifact, resolveCheckpointOutputPath, writeEncryptedArtifact } from "../artifacts/files.ts";
44
+ import { readEncryptedArtifact, resolveCheckpointOutputPath, resolveLatestCheckpointArtifact, writeEncryptedArtifact } from "../artifacts/files.ts";
45
45
  import { requestPassword as showPasswordDialog } from "../ui/password-dialog.tsx";
46
46
  import { showRestoreReportDialog } from "../ui/restore-report-dialog.tsx";
47
47
  import { installSessionPortCommands } from "../ui/sessionport-commands.tsx";
@@ -99,6 +99,7 @@ export async function installSessionPortWiring(
99
99
  cryptoFactory,
100
100
  idDeriver,
101
101
  resolveCheckpointOutputPath,
102
+ resolveLatestCheckpointArtifact,
102
103
  writeEncryptedArtifact,
103
104
  readEncryptedArtifact,
104
105
  });
@@ -4,7 +4,11 @@
4
4
  * Every function here returns a fixed, generic notification object; raw host
5
5
  * or exception text is NEVER interpolated. Classification uses stable error
6
6
  * classes/codes only, never message text, so a failure report cannot leak
7
- * session IDs, paths, host internals, or password material. Exact user-facing
7
+ * session IDs, paths, host internals, or password material. Checkpoint
8
+ * collection failures, restore collection failures, and restore plan failures
9
+ * additionally append a fixed human reason and the exact machine code, but
10
+ * only for `SessionCollectionError`/`RestorePlanError` codes on explicit
11
+ * allowlists; unknown or forged codes keep the generic text. Exact user-facing
8
12
  * copy remains OPEN (OQ-15); these strings are the wiring lane's generic
9
13
  * sanitized categories.
10
14
  *
@@ -30,8 +34,8 @@ import { ArtifactFileError } from "../artifacts/files.ts";
30
34
  import { HostAdapterError, ProjectDestinationError } from "../adapters/index.ts";
31
35
  import { LocationResolutionError } from "../adapters/location-resolver.ts";
32
36
  import { CryptoContainerError } from "../../core/crypto/errors.ts";
33
- import { RestorePlanError } from "../../core/restore/planner.ts";
34
- import { SessionCollectionError } from "../../core/sessions/index.ts";
37
+ import { RestorePlanError, type RestorePlanErrorCode } from "../../core/restore/planner.ts";
38
+ import { SessionCollectionError, type SessionCollectionErrorCode } from "../../core/sessions/index.ts";
35
39
  import { RESTORE_PASSWORD_ATTEMPT_LIMIT } from "../orchestration/index.ts";
36
40
  import type { CheckpointOutcome, RestoreExecutionResult } from "../orchestration/index.ts";
37
41
  import type {
@@ -94,7 +98,7 @@ export function reportCheckpointFailure(error: unknown): SessionPortNotification
94
98
  case "capability":
95
99
  return checkpointFailure("This OpenCode host does not expose the required session-listing capability; no artifact was written.");
96
100
  case "collection":
97
- return checkpointFailure("Collecting the project's sessions failed; no artifact was written.");
101
+ return checkpointFailure(checkpointCollectionFailureMessage(error));
98
102
  case "path-exists":
99
103
  return checkpointFailure("An artifact already exists at that path; nothing was overwritten.");
100
104
  case "artifact-path":
@@ -116,15 +120,103 @@ function checkpointFailure(message: string): SessionPortNotification {
116
120
  return { title: "Checkpoint failed", message, variant: "error" };
117
121
  }
118
122
 
123
+ /** Existing generic checkpoint collection failure copy; also the fallback for unknown codes. */
124
+ const CHECKPOINT_COLLECTION_FAILURE = "Collecting the project's sessions failed; no artifact was written.";
125
+
126
+ /**
127
+ * Fixed human reason per known `SessionCollectionError` code. This record is
128
+ * the shared explicit allowlist for both the checkpoint and the restore
129
+ * collection category: the raw code is echoed to the user only when it
130
+ * exactly matches one of these keys, and the raw `error.message` (which can
131
+ * contain session/project IDs, paths, and host error text) is never used.
132
+ * Adding a code to `SessionCollectionErrorCode` fails compilation until a
133
+ * fixed reason is added here.
134
+ */
135
+ const COLLECTION_FAILURE_REASONS: Readonly<Record<SessionCollectionErrorCode, string>> = {
136
+ "invalid-request": "the session collection request was invalid",
137
+ "invalid-policy": "the session collection policy was invalid",
138
+ "session-list-failed": "the host session list could not be read",
139
+ "message-list-failed": "a session's message history could not be read",
140
+ "malformed-session-page": "the host returned a malformed session list page",
141
+ "malformed-message-page": "the host returned a malformed message list page",
142
+ "malformed-session-record": "the host returned a malformed session record",
143
+ "malformed-message-record": "the host returned a malformed message record",
144
+ "session-project-mismatch": "a listed session did not belong to the requested project",
145
+ "duplicate-session-id": "the host returned a session more than once",
146
+ "duplicate-message-id": "the host returned a message more than once",
147
+ "repeated-session-cursor": "the host repeated a session list cursor",
148
+ "repeated-message-cursor": "the host repeated a message list cursor",
149
+ "session-limit-exceeded": "the session count exceeded the configured limit",
150
+ "message-limit-exceeded": "a session's message count exceeded the configured limit",
151
+ "total-message-limit-exceeded": "the total message count exceeded the configured limit",
152
+ "unsettled-message": "the project history contains an unfinished message",
153
+ };
154
+
155
+ /**
156
+ * Checkpoint collection failure copy: the existing generic message plus
157
+ * `Reason: <fixed human reason> (<known code>).` for a `SessionCollectionError`
158
+ * whose code is on the allowlist. Any other error or code keeps the generic
159
+ * text; only allowlisted literal code strings are ever echoed.
160
+ */
161
+ function checkpointCollectionFailureMessage(error: unknown): string {
162
+ return withKnownCollectionReason(error, CHECKPOINT_COLLECTION_FAILURE);
163
+ }
164
+
165
+ /**
166
+ * Shared allowlist lookup for collection failures: appends
167
+ * `Reason: <fixed human reason> (<known code>).` to `base` for a
168
+ * `SessionCollectionError` whose code is on the fixed map. Any other error,
169
+ * non-instance object, or unknown/forged code keeps `base` unchanged; only
170
+ * allowlisted literal code strings are ever echoed.
171
+ */
172
+ function withKnownCollectionReason(error: unknown, base: string): string {
173
+ if (!(error instanceof SessionCollectionError)) return base;
174
+ const code: string = error.code;
175
+ if (!Object.hasOwn(COLLECTION_FAILURE_REASONS, code)) return base;
176
+ const reason = COLLECTION_FAILURE_REASONS[code as SessionCollectionErrorCode];
177
+ return `${base} Reason: ${reason} (${code}).`;
178
+ }
179
+
119
180
  /* ------------------------------------------------------------------------- */
120
181
  /* Restore reports */
121
182
  /* ------------------------------------------------------------------------- */
122
183
 
123
- /** The artifact path argument is required; nothing was read or changed. */
184
+ /**
185
+ * Exported for compatibility. Bare `/restore` now resolves the newest
186
+ * checkpoint automatically, so the current wiring does not show this usage
187
+ * copy; it documents the optional artifact-path argument.
188
+ */
124
189
  export function reportRestoreUsage(): SessionPortNotification {
125
190
  return {
126
- title: "Restore needs an artifact path",
127
- message: "Usage: /restore <path-to-checkpoint>. No sessions were changed.",
191
+ title: "Restore usage",
192
+ message:
193
+ "Usage: /restore [artifact-path]. Without a path, the newest checkpoint in the current project's .sessionport directory is used. No sessions were changed.",
194
+ variant: "warning",
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Bare `/restore` with no active project directory: fixed, specific copy with
200
+ * no raw path/error text; nothing was read or changed.
201
+ */
202
+ export function reportRestoreNoActiveProject(): SessionPortNotification {
203
+ return {
204
+ title: "Restore unavailable",
205
+ message:
206
+ "The active project directory is unavailable, so the latest checkpoint could not be found; no sessions were changed.",
207
+ variant: "warning",
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Bare `/restore` with no checkpoint in the active project's default
213
+ * `.sessionport/` directory: fixed friendly copy; nothing was read or changed.
214
+ */
215
+ export function reportRestoreNoCheckpointFound(): SessionPortNotification {
216
+ return {
217
+ title: "No checkpoint found",
218
+ message:
219
+ "No checkpoint artifact was found in the current project's .sessionport directory; no sessions were changed. Create one with /checkpoint or pass an artifact path to /restore.",
128
220
  variant: "warning",
129
221
  };
130
222
  }
@@ -157,6 +249,59 @@ export function reportRestoreAuthenticationFailed(): SessionPortNotification {
157
249
  };
158
250
  }
159
251
 
252
+ /** Existing generic restore collection failure copy; also the fallback for unknown codes. */
253
+ const RESTORE_COLLECTION_FAILURE = "The destination project's sessions could not be read; no sessions were changed.";
254
+
255
+ /** Existing generic restore plan failure copy; also the fallback for unknown codes. */
256
+ const RESTORE_PLAN_FAILURE = "The checkpoint does not match this destination safely; no sessions were changed.";
257
+
258
+ /**
259
+ * Fixed human reason per known `RestorePlanError` code — the explicit
260
+ * allowlist for the restore plan category. As with collection codes, the raw
261
+ * code is echoed only on an exact key match and the raw `error.message` (which
262
+ * can contain session/message IDs and labels) is never used. Adding a code to
263
+ * `RestorePlanErrorCode` fails compilation until a fixed reason is added here.
264
+ */
265
+ const RESTORE_PLAN_REASONS: Readonly<Record<RestorePlanErrorCode, string>> = {
266
+ "invalid-input": "the restore planning request was invalid",
267
+ "malformed-payload": "the checkpoint payload was malformed",
268
+ "malformed-destination": "the destination session history could not be read",
269
+ "divergent-artifact-session": "the checkpoint repeats a session with different content",
270
+ "duplicate-artifact-message-id": "the checkpoint repeats a message ID across sessions",
271
+ "ambiguous-destination": "the destination repeats a session or message ID",
272
+ "dangling-session-reference": "a session reference does not resolve in the destination or plan",
273
+ "dangling-message-reference": "a message reference does not resolve in the destination",
274
+ "session-id-collision": "a planned session ID collides with existing session history",
275
+ "message-id-collision": "a planned message ID collides with existing session history",
276
+ "dependency-cycle": "session parent/reference relationships contain a cycle",
277
+ "derivation-failure": "a remapped session or message ID could not be derived",
278
+ };
279
+
280
+ /**
281
+ * Restore collection failure copy: the existing restore base message plus
282
+ * `Reason: <fixed human reason> (<known code>).` for a known
283
+ * `SessionCollectionError` code (shared allowlist with the checkpoint
284
+ * category). Unknown/forged codes and non-instance errors keep the base text;
285
+ * raw messages, IDs, and paths never leak.
286
+ */
287
+ function restoreCollectionFailureMessage(error: unknown): string {
288
+ return withKnownCollectionReason(error, RESTORE_COLLECTION_FAILURE);
289
+ }
290
+
291
+ /**
292
+ * Restore plan failure copy: the existing restore base message plus
293
+ * `Reason: <fixed human reason> (<known code>).` for a `RestorePlanError`
294
+ * whose code is on the allowlist. Unknown/forged codes and non-instance
295
+ * errors keep the base text; raw plan messages (IDs/labels) never leak.
296
+ */
297
+ function restorePlanFailureMessage(error: unknown): string {
298
+ if (!(error instanceof RestorePlanError)) return RESTORE_PLAN_FAILURE;
299
+ const code: string = error.code;
300
+ if (!Object.hasOwn(RESTORE_PLAN_REASONS, code)) return RESTORE_PLAN_FAILURE;
301
+ const reason = RESTORE_PLAN_REASONS[code as RestorePlanErrorCode];
302
+ return `${RESTORE_PLAN_FAILURE} Reason: ${reason} (${code}).`;
303
+ }
304
+
160
305
  /**
161
306
  * Reports one restore failure as a fixed, sanitized category. `executionStarted`
162
307
  * is true only once the import executor was invoked; the copy never claims
@@ -184,9 +329,9 @@ export function reportRestoreFailure(error: unknown, executionStarted: boolean):
184
329
  case "destinations":
185
330
  return restoreFailure("The host project list could not be read; no sessions were changed.");
186
331
  case "collection":
187
- return restoreFailure("The destination project's sessions could not be read; no sessions were changed.");
332
+ return restoreFailure(restoreCollectionFailureMessage(error));
188
333
  case "plan":
189
- return restoreFailure("The checkpoint does not match this destination safely; no sessions were changed.");
334
+ return restoreFailure(restorePlanFailureMessage(error));
190
335
  case "artifact-path":
191
336
  return restoreFailure("The artifact path is not usable; no sessions were changed.");
192
337
  case "path-exists":