respawn-session 0.0.4 → 0.0.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
@@ -107,6 +107,16 @@ Show every saved session in your local index:
107
107
  respawn list
108
108
  ```
109
109
 
110
+ ### Import Existing Sessions
111
+
112
+ Backfill sessions that already exist on this machine:
113
+
114
+ ```sh
115
+ respawn import
116
+ ```
117
+
118
+ Import scans Claude Code and Codex transcripts, groups them by their recorded cwd, and saves sessions whose cwd is still an available git worktree. It skips transcripts that are already in `~/.respawn/index.json` and skips deleted or non-git worktrees.
119
+
110
120
  ## Commands
111
121
 
112
122
  | Command | What it does |
@@ -115,6 +125,7 @@ respawn list
115
125
  | `respawn save` | Saves the active Claude Code or Codex transcript |
116
126
  | `respawn autosave` | Saves only if the transcript changed |
117
127
  | `respawn tag` | Saves and attaches session metadata to the current PR |
128
+ | `respawn import` | Backfills existing local Claude Code and Codex sessions |
118
129
  | `respawn <branch>` | Restores the newest session for a branch |
119
130
  | `respawn <pr-number>` | Restores the newest session from a tagged PR |
120
131
  | `respawn <pr-url>` | Restores the newest session from a tagged PR URL |
@@ -122,6 +133,12 @@ respawn list
122
133
  | `respawn version` | Prints the installed CLI version |
123
134
  | `respawn update` | Updates the global npm install to the latest release |
124
135
 
136
+ If your installed version does not recognize `respawn update`, bootstrap once with:
137
+
138
+ ```sh
139
+ npm install -g respawn-session@latest
140
+ ```
141
+
125
142
  ## How It Works
126
143
 
127
144
  `respawn save` detects the active agent in this order:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "respawn-session",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Save and resume Claude Code or Codex agent sessions by git branch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import type { LocatedTranscript, LocateOptions } from "./types";
4
+ import type { ImportableTranscript, LocatedTranscript, LocateOptions } from "./types";
5
5
 
6
6
  export function encodeClaudeProjectPath(cwd: string): string {
7
7
  return cwd.replace(/[^A-Za-z0-9._-]/g, "-");
@@ -39,6 +39,37 @@ export function resumeCmd(sessionId: string): string[] {
39
39
  return ["claude", "--resume", sessionId];
40
40
  }
41
41
 
42
+ export function listTranscripts(options: LocateOptions = {}): ImportableTranscript[] {
43
+ const home = options.home ?? homedir();
44
+ const sessionsDir = join(home, ".claude", "sessions");
45
+ if (!existsSync(sessionsDir)) return [];
46
+
47
+ return readdirSync(sessionsDir, { withFileTypes: true })
48
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
49
+ .map((entry) => readSessionRecord(join(sessionsDir, entry.name)))
50
+ .filter(
51
+ (record): record is Required<Pick<ClaudeSessionRecord, "sessionId" | "cwd">> &
52
+ ClaudeSessionRecord => Boolean(record?.sessionId && record.cwd),
53
+ )
54
+ .map((record): ImportableTranscript | null => {
55
+ const path = transcriptPath(record.sessionId, {
56
+ ...options,
57
+ cwd: record.cwd,
58
+ });
59
+ if (!existsSync(path)) return null;
60
+ return {
61
+ agent: "claude" as const,
62
+ path,
63
+ sessionId: record.sessionId,
64
+ cwd: record.cwd,
65
+ savedAt: record.updatedAt
66
+ ? new Date(record.updatedAt).toISOString()
67
+ : undefined,
68
+ };
69
+ })
70
+ .filter((transcript): transcript is ImportableTranscript => transcript !== null);
71
+ }
72
+
42
73
  type ClaudeSessionRecord = {
43
74
  sessionId?: string;
44
75
  cwd?: string;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, relative } from "node:path";
4
- import type { LocatedTranscript, LocateOptions } from "./types";
4
+ import type { ImportableTranscript, LocatedTranscript, LocateOptions } from "./types";
5
5
 
6
6
  const uuidPattern =
7
7
  /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
@@ -54,6 +54,30 @@ export function resumeCmd(sessionId: string): string[] {
54
54
  return ["codex", "resume", sessionId];
55
55
  }
56
56
 
57
+ export function listTranscripts(options: LocateOptions = {}): ImportableTranscript[] {
58
+ const home = options.home ?? homedir();
59
+ const sessionsDir = codexSessionsDir(home);
60
+ if (!existsSync(sessionsDir)) return [];
61
+
62
+ return walkJsonl(sessionsDir)
63
+ .map((path): ImportableTranscript | null => {
64
+ const meta = readCodexMeta(path);
65
+ const sessionId = meta?.id ?? sessionIdFromCodexPath(path);
66
+ if (!sessionId || !meta?.cwd) return null;
67
+
68
+ const rel = relative(sessionsDir, path);
69
+ return {
70
+ agent: "codex" as const,
71
+ path,
72
+ sessionId,
73
+ cwd: meta.cwd,
74
+ relativePath: rel.startsWith("..") ? undefined : rel,
75
+ savedAt: statSync(path).mtime.toISOString(),
76
+ };
77
+ })
78
+ .filter((transcript): transcript is ImportableTranscript => transcript !== null);
79
+ }
80
+
57
81
  function locatedFromPath(
58
82
  path: string,
59
83
  cwd: string,
@@ -1,4 +1,5 @@
1
1
  import type { LocatedTranscript, LocateOptions } from "./types";
2
+ import type { ImportableTranscript } from "./types";
2
3
  import * as claude from "./claude";
3
4
  import * as codex from "./codex";
4
5
 
@@ -11,6 +12,10 @@ export function locateActiveTranscript(
11
12
  );
12
13
  }
13
14
 
15
+ export function listAllTranscripts(options: LocateOptions = {}): ImportableTranscript[] {
16
+ return [...claude.listTranscripts(options), ...codex.listTranscripts(options)];
17
+ }
18
+
14
19
  export function resumeCmd(agent: LocatedTranscript["agent"], sessionId: string): string[] {
15
20
  if (agent === "claude") return claude.resumeCmd(sessionId);
16
21
  return codex.resumeCmd(sessionId);
@@ -7,6 +7,11 @@ export type LocatedTranscript = {
7
7
  relativePath?: string;
8
8
  };
9
9
 
10
+ export type ImportableTranscript = LocatedTranscript & {
11
+ cwd: string;
12
+ savedAt?: string;
13
+ };
14
+
10
15
  export type LocateOptions = {
11
16
  cwd?: string;
12
17
  home?: string;
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { initRespawn } from "./commands/init";
3
+ import { importSessions } from "./commands/import";
3
4
  import { listSessions } from "./commands/list";
4
5
  import { resumePrSession, resumeSession } from "./commands/resume";
5
6
  import { saveSession } from "./commands/save";
@@ -12,6 +13,7 @@ export type Route =
12
13
  | { name: "autosave" }
13
14
  | { name: "list" }
14
15
  | { name: "init" }
16
+ | { name: "import" }
15
17
  | { name: "tag" }
16
18
  | { name: "version" }
17
19
  | { name: "update" }
@@ -31,6 +33,7 @@ export function route(args: string[]): Route {
31
33
  command === "autosave" ||
32
34
  command === "list" ||
33
35
  command === "init" ||
36
+ command === "import" ||
34
37
  command === "tag" ||
35
38
  command === "update"
36
39
  ) {
@@ -64,6 +67,10 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
64
67
  console.log(await initRespawn());
65
68
  return;
66
69
  }
70
+ if (selected.name === "import") {
71
+ console.log((await importSessions()).message);
72
+ return;
73
+ }
67
74
  if (selected.name === "tag") {
68
75
  console.log((await tagCurrentPr()).message);
69
76
  return;
@@ -100,6 +107,7 @@ function helpText(): string {
100
107
  " respawn <pr-url|number>",
101
108
  " respawn list",
102
109
  " respawn init",
110
+ " respawn import",
103
111
  " respawn version",
104
112
  " respawn update",
105
113
  ].join("\n");
@@ -0,0 +1,98 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { listAllTranscripts } from "../agents";
4
+ import type { ImportableTranscript } from "../agents/types";
5
+ import { gitInfoForCwd, type GitInfo } from "../git";
6
+ import {
7
+ defaultIndexPath,
8
+ readIndex,
9
+ recordSession,
10
+ type SavedSession,
11
+ } from "../index-file";
12
+ import { createGist } from "../storage/gist";
13
+
14
+ export type ImportResult = {
15
+ imported: number;
16
+ duplicates: number;
17
+ skipped: number;
18
+ message: string;
19
+ };
20
+
21
+ export type ImportDeps = {
22
+ indexPath?: string;
23
+ listTranscripts?: typeof listAllTranscripts;
24
+ gitInfoForCwd?: typeof gitInfoForCwd;
25
+ createGist?: typeof createGist;
26
+ now?: () => Date;
27
+ };
28
+
29
+ export async function importSessions(
30
+ deps: ImportDeps = {},
31
+ ): Promise<ImportResult> {
32
+ const indexPath = deps.indexPath ?? defaultIndexPath();
33
+ const transcripts = (deps.listTranscripts ?? listAllTranscripts)();
34
+ const gitInfo = deps.gitInfoForCwd ?? gitInfoForCwd;
35
+ const upload = deps.createGist ?? createGist;
36
+ let imported = 0;
37
+ let duplicates = 0;
38
+ let skipped = 0;
39
+
40
+ for (const transcript of transcripts) {
41
+ const info = await gitInfo(transcript.cwd);
42
+ if (!info) {
43
+ skipped += 1;
44
+ continue;
45
+ }
46
+
47
+ const transcriptHash = await hashFile(transcript.path);
48
+ if (await isDuplicate(indexPath, info, transcript, transcriptHash)) {
49
+ duplicates += 1;
50
+ continue;
51
+ }
52
+
53
+ const gistUrl = await upload(
54
+ transcript.path,
55
+ `respawn: ${info.repo}@${info.branch}`,
56
+ );
57
+ const session: SavedSession = {
58
+ repo: info.repo,
59
+ branch: info.branch,
60
+ gistUrl,
61
+ sessionId: transcript.sessionId,
62
+ sha: info.sha,
63
+ agent: transcript.agent,
64
+ savedAt: transcript.savedAt ?? (deps.now ?? (() => new Date()))().toISOString(),
65
+ relativePath: transcript.relativePath,
66
+ transcriptHash,
67
+ };
68
+ await recordSession(indexPath, session);
69
+ imported += 1;
70
+ }
71
+
72
+ return {
73
+ imported,
74
+ duplicates,
75
+ skipped,
76
+ message: `Imported ${imported} sessions, skipped ${duplicates} duplicates and ${skipped} unavailable worktrees`,
77
+ };
78
+ }
79
+
80
+ async function isDuplicate(
81
+ indexPath: string,
82
+ info: GitInfo,
83
+ transcript: ImportableTranscript,
84
+ transcriptHash: string,
85
+ ): Promise<boolean> {
86
+ const index = await readIndex(indexPath);
87
+ return index.sessions.some(
88
+ (session) =>
89
+ session.repo === info.repo &&
90
+ session.branch === info.branch &&
91
+ session.agent === transcript.agent &&
92
+ session.transcriptHash === transcriptHash,
93
+ );
94
+ }
95
+
96
+ async function hashFile(path: string): Promise<string> {
97
+ return createHash("sha256").update(await readFile(path)).digest("hex");
98
+ }
package/src/git.ts CHANGED
@@ -4,6 +4,12 @@ export async function currentRepo(run: RunCommand = runCommand): Promise<string>
4
4
  return (await run("git", ["remote", "get-url", "origin"])).trim();
5
5
  }
6
6
 
7
+ export type GitInfo = {
8
+ repo: string;
9
+ branch: string;
10
+ sha: string;
11
+ };
12
+
7
13
  export async function currentBranch(
8
14
  run: RunCommand = runCommand,
9
15
  ): Promise<string> {
@@ -20,3 +26,18 @@ export async function checkoutBranch(
20
26
  ): Promise<void> {
21
27
  await run("git", ["checkout", branch]);
22
28
  }
29
+
30
+ export async function gitInfoForCwd(
31
+ cwd: string,
32
+ run: RunCommand = runCommand,
33
+ ): Promise<GitInfo | null> {
34
+ try {
35
+ const repo = (await run("git", ["-C", cwd, "remote", "get-url", "origin"])).trim();
36
+ const branch = (await run("git", ["-C", cwd, "branch", "--show-current"])).trim();
37
+ const sha = (await run("git", ["-C", cwd, "rev-parse", "HEAD"])).trim();
38
+ if (!repo || !branch || !sha) return null;
39
+ return { repo, branch, sha };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }