opencode-session-port 0.1.4 → 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.4).** 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 is the current local candidate. 0.1.4 is a diagnostics/copy patch only: a failed checkpoint collection now appends a fixed sanitized reason and the known machine code (`Reason: <fixed reason> (<code>).`) for the 17 current collection codes; unknown codes keep the generic text and raw host/exception text is never shown. Collector behavior is unchanged, and no root cause is established for an earlier Windows collection failure; a user later reported a local checkpoint succeeding from the repository checkout, which is not attributed to this patch. 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.
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.4"]
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.4
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. 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 candidate tarball passed the same local manifest/allowlist and packed-entry checks (including the sanitized collection-failure copy); it is not published.
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");
@@ -5886,18 +5931,35 @@ function checkpointFailure(message) {
5886
5931
  return { title: "Checkpoint failed", message, variant: "error" };
5887
5932
  }
5888
5933
  function checkpointCollectionFailureMessage(error) {
5934
+ return withKnownCollectionReason(error, CHECKPOINT_COLLECTION_FAILURE);
5935
+ }
5936
+ function withKnownCollectionReason(error, base) {
5889
5937
  if (!(error instanceof SessionCollectionError))
5890
- return CHECKPOINT_COLLECTION_FAILURE;
5938
+ return base;
5891
5939
  const code = error.code;
5892
- if (!Object.hasOwn(CHECKPOINT_COLLECTION_REASONS, code))
5893
- return CHECKPOINT_COLLECTION_FAILURE;
5894
- const reason = CHECKPOINT_COLLECTION_REASONS[code];
5895
- return `${CHECKPOINT_COLLECTION_FAILURE} Reason: ${reason} (${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}).`;
5896
5944
  }
5897
5945
  function reportRestoreUsage() {
5898
5946
  return {
5899
- title: "Restore needs an artifact path",
5900
- 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.",
5901
5963
  variant: "warning"
5902
5964
  };
5903
5965
  }
@@ -5922,6 +5984,18 @@ function reportRestoreAuthenticationFailed() {
5922
5984
  variant: "error"
5923
5985
  };
5924
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
+ }
5925
5999
  function reportRestoreFailure(error, executionStarted) {
5926
6000
  if (executionStarted) {
5927
6001
  return {
@@ -5941,9 +6015,9 @@ function reportRestoreFailure(error, executionStarted) {
5941
6015
  case "destinations":
5942
6016
  return restoreFailure("The host project list could not be read; no sessions were changed.");
5943
6017
  case "collection":
5944
- return restoreFailure("The destination project's sessions could not be read; no sessions were changed.");
6018
+ return restoreFailure(restoreCollectionFailureMessage(error));
5945
6019
  case "plan":
5946
- return restoreFailure("The checkpoint does not match this destination safely; no sessions were changed.");
6020
+ return restoreFailure(restorePlanFailureMessage(error));
5947
6021
  case "artifact-path":
5948
6022
  return restoreFailure("The artifact path is not usable; no sessions were changed.");
5949
6023
  case "path-exists":
@@ -6048,7 +6122,7 @@ function classifyFailure(error) {
6048
6122
  }
6049
6123
  return "unknown";
6050
6124
  }
6051
- var CHECKPOINT_COLLECTION_FAILURE = "Collecting the project's sessions failed; no artifact was written.", CHECKPOINT_COLLECTION_REASONS;
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;
6052
6126
  var init_reports = __esm(() => {
6053
6127
  init_files();
6054
6128
  init_adapters();
@@ -6057,7 +6131,7 @@ var init_reports = __esm(() => {
6057
6131
  init_planner();
6058
6132
  init_sessions();
6059
6133
  init_orchestration();
6060
- CHECKPOINT_COLLECTION_REASONS = {
6134
+ COLLECTION_FAILURE_REASONS = {
6061
6135
  "invalid-request": "the session collection request was invalid",
6062
6136
  "invalid-policy": "the session collection policy was invalid",
6063
6137
  "session-list-failed": "the host session list could not be read",
@@ -6076,6 +6150,20 @@ var init_reports = __esm(() => {
6076
6150
  "total-message-limit-exceeded": "the total message count exceeded the configured limit",
6077
6151
  "unsettled-message": "the project history contains an unfinished message"
6078
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
+ };
6079
6167
  });
6080
6168
 
6081
6169
  // src/tui/wiring/composition.ts
@@ -6088,6 +6176,7 @@ function createSessionPortHandlers(dependencies) {
6088
6176
  requireCallable(dependencies.showRestoreReport, "showRestoreReport");
6089
6177
  requireCallable(dependencies.requestPassword, "requestPassword");
6090
6178
  requireCallable(dependencies.resolveCheckpointOutputPath, "resolveCheckpointOutputPath");
6179
+ requireCallable(dependencies.resolveLatestCheckpointArtifact, "resolveLatestCheckpointArtifact");
6091
6180
  requireCallable(dependencies.writeEncryptedArtifact, "writeEncryptedArtifact");
6092
6181
  requireCallable(dependencies.readEncryptedArtifact, "readEncryptedArtifact");
6093
6182
  if (!isRecordValue(dependencies.cryptoFactory) || typeof dependencies.cryptoFactory.create !== "function") {
@@ -6134,11 +6223,7 @@ async function runCheckpointCommand(dependencies, rawInput) {
6134
6223
  }
6135
6224
  }
6136
6225
  async function runRestoreCommand(dependencies, rawInput) {
6137
- const artifactPath = parseRestoreArtifactArgument(rawInput);
6138
- if (artifactPath === undefined) {
6139
- dependencies.notify(reportRestoreUsage());
6140
- return;
6141
- }
6226
+ const explicitArtifactPath = parseRestoreArtifactArgument(rawInput);
6142
6227
  const capability = probeSessionImportCapability(dependencies.client);
6143
6228
  if (!capability.available) {
6144
6229
  dependencies.notify(reportRestoreCapabilityUnavailable());
@@ -6146,6 +6231,22 @@ async function runRestoreCommand(dependencies, rawInput) {
6146
6231
  }
6147
6232
  let executionStarted = false;
6148
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
+ }
6149
6250
  const destinations = await createProjectDestinationsAdapter(dependencies.client).listDestinations();
6150
6251
  const selected = await selectRestoreProject(dependencies.dialog, destinations);
6151
6252
  if (selected === undefined) {
@@ -10243,6 +10344,7 @@ async function installSessionPortWiring(context, options = {}) {
10243
10344
  cryptoFactory,
10244
10345
  idDeriver,
10245
10346
  resolveCheckpointOutputPath,
10347
+ resolveLatestCheckpointArtifact,
10246
10348
  writeEncryptedArtifact,
10247
10349
  readEncryptedArtifact
10248
10350
  });
@@ -10268,6 +10370,8 @@ var init_install = __esm(() => {
10268
10370
  var exports_wiring = {};
10269
10371
  __export(exports_wiring, {
10270
10372
  reportRestoreUsage: () => reportRestoreUsage,
10373
+ reportRestoreNoCheckpointFound: () => reportRestoreNoCheckpointFound,
10374
+ reportRestoreNoActiveProject: () => reportRestoreNoActiveProject,
10271
10375
  reportRestoreFailure: () => reportRestoreFailure,
10272
10376
  reportRestoreExecution: () => reportRestoreExecution,
10273
10377
  reportRestoreCapabilityUnavailable: () => reportRestoreCapabilityUnavailable,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-port",
3
- "version": "0.1.4",
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
  });
@@ -5,9 +5,10 @@
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
7
  * session IDs, paths, host internals, or password material. Checkpoint
8
- * collection failures additionally append a fixed human reason and the exact
9
- * machine code, but only for `SessionCollectionError` codes on an explicit
10
- * allowlist; unknown or forged codes keep the generic text. Exact user-facing
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
11
12
  * copy remains OPEN (OQ-15); these strings are the wiring lane's generic
12
13
  * sanitized categories.
13
14
  *
@@ -33,7 +34,7 @@ import { ArtifactFileError } from "../artifacts/files.ts";
33
34
  import { HostAdapterError, ProjectDestinationError } from "../adapters/index.ts";
34
35
  import { LocationResolutionError } from "../adapters/location-resolver.ts";
35
36
  import { CryptoContainerError } from "../../core/crypto/errors.ts";
36
- import { RestorePlanError } from "../../core/restore/planner.ts";
37
+ import { RestorePlanError, type RestorePlanErrorCode } from "../../core/restore/planner.ts";
37
38
  import { SessionCollectionError, type SessionCollectionErrorCode } from "../../core/sessions/index.ts";
38
39
  import { RESTORE_PASSWORD_ATTEMPT_LIMIT } from "../orchestration/index.ts";
39
40
  import type { CheckpointOutcome, RestoreExecutionResult } from "../orchestration/index.ts";
@@ -124,13 +125,14 @@ const CHECKPOINT_COLLECTION_FAILURE = "Collecting the project's sessions failed;
124
125
 
125
126
  /**
126
127
  * Fixed human reason per known `SessionCollectionError` code. This record is
127
- * the explicit allowlist: the raw code is echoed to the user only when it
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
128
130
  * exactly matches one of these keys, and the raw `error.message` (which can
129
131
  * contain session/project IDs, paths, and host error text) is never used.
130
132
  * Adding a code to `SessionCollectionErrorCode` fails compilation until a
131
133
  * fixed reason is added here.
132
134
  */
133
- const CHECKPOINT_COLLECTION_REASONS: Readonly<Record<SessionCollectionErrorCode, string>> = {
135
+ const COLLECTION_FAILURE_REASONS: Readonly<Record<SessionCollectionErrorCode, string>> = {
134
136
  "invalid-request": "the session collection request was invalid",
135
137
  "invalid-policy": "the session collection policy was invalid",
136
138
  "session-list-failed": "the host session list could not be read",
@@ -157,22 +159,64 @@ const CHECKPOINT_COLLECTION_REASONS: Readonly<Record<SessionCollectionErrorCode,
157
159
  * text; only allowlisted literal code strings are ever echoed.
158
160
  */
159
161
  function checkpointCollectionFailureMessage(error: unknown): string {
160
- if (!(error instanceof SessionCollectionError)) return CHECKPOINT_COLLECTION_FAILURE;
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;
161
174
  const code: string = error.code;
162
- if (!Object.hasOwn(CHECKPOINT_COLLECTION_REASONS, code)) return CHECKPOINT_COLLECTION_FAILURE;
163
- const reason = CHECKPOINT_COLLECTION_REASONS[code as SessionCollectionErrorCode];
164
- return `${CHECKPOINT_COLLECTION_FAILURE} Reason: ${reason} (${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}).`;
165
178
  }
166
179
 
167
180
  /* ------------------------------------------------------------------------- */
168
181
  /* Restore reports */
169
182
  /* ------------------------------------------------------------------------- */
170
183
 
171
- /** 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
+ */
172
189
  export function reportRestoreUsage(): SessionPortNotification {
173
190
  return {
174
- title: "Restore needs an artifact path",
175
- 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.",
176
220
  variant: "warning",
177
221
  };
178
222
  }
@@ -205,6 +249,59 @@ export function reportRestoreAuthenticationFailed(): SessionPortNotification {
205
249
  };
206
250
  }
207
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
+
208
305
  /**
209
306
  * Reports one restore failure as a fixed, sanitized category. `executionStarted`
210
307
  * is true only once the import executor was invoked; the copy never claims
@@ -232,9 +329,9 @@ export function reportRestoreFailure(error: unknown, executionStarted: boolean):
232
329
  case "destinations":
233
330
  return restoreFailure("The host project list could not be read; no sessions were changed.");
234
331
  case "collection":
235
- return restoreFailure("The destination project's sessions could not be read; no sessions were changed.");
332
+ return restoreFailure(restoreCollectionFailureMessage(error));
236
333
  case "plan":
237
- return restoreFailure("The checkpoint does not match this destination safely; no sessions were changed.");
334
+ return restoreFailure(restorePlanFailureMessage(error));
238
335
  case "artifact-path":
239
336
  return restoreFailure("The artifact path is not usable; no sessions were changed.");
240
337
  case "path-exists":