respawn-session 0.0.1 → 0.0.4

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
@@ -17,15 +17,15 @@ bun --version
17
17
  gh auth status
18
18
  ```
19
19
 
20
- ## Usage
20
+ ## Quick Start
21
21
 
22
- Initialize local storage and install a Claude Code Stop hook:
22
+ Initialize once on each machine. This creates `~/.respawn/index.json` and installs autosave Stop hooks for Claude Code and Codex:
23
23
 
24
24
  ```sh
25
25
  respawn init
26
26
  ```
27
27
 
28
- Save the current active agent session from inside a git worktree:
28
+ Work normally in Claude Code or Codex. `respawn init` makes sessions autosave when the agent stops. To save immediately from inside an active agent session, run:
29
29
 
30
30
  ```sh
31
31
  respawn save
@@ -37,58 +37,96 @@ Resume the latest saved session for a branch:
37
37
  respawn angela/fix-bugs
38
38
  ```
39
39
 
40
- List saved sessions:
40
+ Resume from a PR that was tagged with `respawn tag`:
41
41
 
42
42
  ```sh
43
- respawn list
43
+ respawn 123
44
+ respawn https://github.com/org/repo/pull/123
44
45
  ```
45
46
 
46
- ## Publishing
47
+ ## Common Workflows
48
+
49
+ ### Manual Branch Save
47
50
 
48
- Before publishing, confirm the local runtime and GitHub CLI auth are ready:
51
+ Inside an active Claude Code or Codex session:
49
52
 
50
53
  ```sh
51
- bun --version
52
- gh auth status
54
+ respawn save
53
55
  ```
54
56
 
55
- Run npm's packaging checks first:
57
+ Later, from a clone or worktree for the same repo:
56
58
 
57
59
  ```sh
58
- npm pack --dry-run
59
- npm publish --dry-run
60
+ respawn <branch>
60
61
  ```
61
62
 
62
- Publish the package:
63
+ Example:
63
64
 
64
65
  ```sh
65
- npm publish
66
+ respawn angela/fix-bugs
66
67
  ```
67
68
 
68
- If npm returns a 403 saying two-factor authentication is required, publish with a current OTP code:
69
+ ### Autosave
70
+
71
+ Run this once per machine:
69
72
 
70
73
  ```sh
71
- npm publish --otp <your-current-2fa-code>
74
+ respawn init
72
75
  ```
73
76
 
74
- After publish, users can install the CLI globally:
77
+ After that, Claude Code and Codex Stop hooks run:
75
78
 
76
79
  ```sh
77
- npm install -g respawn-session
78
- respawn list
80
+ respawn autosave
81
+ ```
82
+
83
+ Autosave hashes the transcript and skips unchanged sessions, so repeated Stop events do not create duplicate gists.
84
+
85
+ ### PR Tagging
86
+
87
+ Use this when you want a session to survive branch deletion after merge:
88
+
89
+ ```sh
90
+ respawn tag
79
91
  ```
80
92
 
81
- One-off `npx` usage can download the package, but the bin still uses `#!/usr/bin/env bun`, so Bun must be installed:
93
+ That writes or updates a hidden metadata comment on the current GitHub PR. The comment stores session pointers, not the transcript body. Transcripts still live in your private gists.
94
+
95
+ Later, resume from the PR:
82
96
 
83
97
  ```sh
84
- npx respawn-session list
98
+ respawn 123
99
+ respawn https://github.com/org/repo/pull/123
85
100
  ```
86
101
 
102
+ ### List Saved Sessions
103
+
104
+ Show every saved session in your local index:
105
+
106
+ ```sh
107
+ respawn list
108
+ ```
109
+
110
+ ## Commands
111
+
112
+ | Command | What it does |
113
+ | --- | --- |
114
+ | `respawn init` | Creates the local index and installs autosave hooks |
115
+ | `respawn save` | Saves the active Claude Code or Codex transcript |
116
+ | `respawn autosave` | Saves only if the transcript changed |
117
+ | `respawn tag` | Saves and attaches session metadata to the current PR |
118
+ | `respawn <branch>` | Restores the newest session for a branch |
119
+ | `respawn <pr-number>` | Restores the newest session from a tagged PR |
120
+ | `respawn <pr-url>` | Restores the newest session from a tagged PR URL |
121
+ | `respawn list` | Lists locally indexed sessions |
122
+ | `respawn version` | Prints the installed CLI version |
123
+ | `respawn update` | Updates the global npm install to the latest release |
124
+
87
125
  ## How It Works
88
126
 
89
127
  `respawn save` detects the active agent in this order:
90
128
 
91
- 1. Claude Code via `CLAUDE_SESSION_ID`
129
+ 1. Claude Code via `CLAUDE_SESSION_ID`, then `~/.claude/sessions/*.json`
92
130
  2. Codex via `CODEX_TUI_SESSION_LOG_PATH`, `CODEX_SESSION_ID`, or the newest `~/.codex/sessions/**.jsonl` transcript for the current cwd
93
131
 
94
132
  It then runs:
@@ -107,6 +145,8 @@ The local index lives at:
107
145
 
108
146
  Branches can have multiple saved sessions. `respawn <branch>` restores the newest `savedAt` entry for the current repo and branch. `respawn list` shows every saved entry so older sessions remain discoverable.
109
147
 
148
+ `respawn tag` writes a hidden metadata comment to the current PR. The comment stores session pointers, not the transcript body. Transcripts still live in your private gists. This lets `respawn <pr-url|number>` recover the newest tagged session after a branch is merged or deleted.
149
+
110
150
  ## Agent Paths
111
151
 
112
152
  Claude Code transcripts are restored to:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "respawn-session",
3
- "version": "0.0.1",
3
+ "version": "0.0.4",
4
4
  "description": "Save and resume Claude Code or Codex agent sessions by git branch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,10 +1,10 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { LocatedTranscript, LocateOptions } from "./types";
5
5
 
6
6
  export function encodeClaudeProjectPath(cwd: string): string {
7
- return cwd.replaceAll("/", "-");
7
+ return cwd.replace(/[^A-Za-z0-9._-]/g, "-");
8
8
  }
9
9
 
10
10
  export function transcriptPath(
@@ -27,14 +27,55 @@ export function locateTranscript(
27
27
  ): LocatedTranscript | null {
28
28
  const env = options.env ?? process.env;
29
29
  const sessionId = env.CLAUDE_SESSION_ID;
30
- if (!sessionId) return null;
30
+ if (sessionId) {
31
+ const path = transcriptPath(sessionId, options);
32
+ if (existsSync(path)) return { agent: "claude", path, sessionId };
33
+ }
31
34
 
32
- const path = transcriptPath(sessionId, options);
33
- if (!existsSync(path)) return null;
34
-
35
- return { agent: "claude", path, sessionId };
35
+ return locateFromSessionRegistry(options);
36
36
  }
37
37
 
38
38
  export function resumeCmd(sessionId: string): string[] {
39
39
  return ["claude", "--resume", sessionId];
40
40
  }
41
+
42
+ type ClaudeSessionRecord = {
43
+ sessionId?: string;
44
+ cwd?: string;
45
+ updatedAt?: number;
46
+ };
47
+
48
+ function locateFromSessionRegistry(
49
+ options: LocateOptions = {},
50
+ ): LocatedTranscript | null {
51
+ const cwd = options.cwd ?? process.cwd();
52
+ const home = options.home ?? homedir();
53
+ const sessionsDir = join(home, ".claude", "sessions");
54
+ if (!existsSync(sessionsDir)) return null;
55
+
56
+ const records = readdirSync(sessionsDir, { withFileTypes: true })
57
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
58
+ .map((entry) => readSessionRecord(join(sessionsDir, entry.name)))
59
+ .filter(
60
+ (record): record is Required<Pick<ClaudeSessionRecord, "sessionId" | "cwd">> &
61
+ ClaudeSessionRecord => Boolean(record?.sessionId && record.cwd === cwd),
62
+ )
63
+ .sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0));
64
+
65
+ for (const record of records.reverse()) {
66
+ const path = transcriptPath(record.sessionId, options);
67
+ if (existsSync(path)) {
68
+ return { agent: "claude", path, sessionId: record.sessionId };
69
+ }
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ function readSessionRecord(path: string): ClaudeSessionRecord | null {
76
+ try {
77
+ return JSON.parse(readFileSync(path, "utf8")) as ClaudeSessionRecord;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
package/src/cli.ts CHANGED
@@ -1,24 +1,44 @@
1
1
  #!/usr/bin/env bun
2
2
  import { initRespawn } from "./commands/init";
3
3
  import { listSessions } from "./commands/list";
4
- import { resumeSession } from "./commands/resume";
4
+ import { resumePrSession, resumeSession } from "./commands/resume";
5
5
  import { saveSession } from "./commands/save";
6
+ import { tagCurrentPr } from "./commands/tag";
7
+ import { updateRespawn, versionText } from "./commands/update";
6
8
 
7
9
  export type Route =
8
10
  | { name: "help" }
9
11
  | { name: "save" }
12
+ | { name: "autosave" }
10
13
  | { name: "list" }
11
14
  | { name: "init" }
12
- | { name: "resume"; branch: string };
15
+ | { name: "tag" }
16
+ | { name: "version" }
17
+ | { name: "update" }
18
+ | { name: "resume"; branch: string }
19
+ | { name: "resume-pr"; prRef: string };
13
20
 
14
21
  export function route(args: string[]): Route {
15
22
  const [command] = args;
16
23
  if (!command || command === "help" || command === "--help" || command === "-h") {
17
24
  return { name: "help" };
18
25
  }
19
- if (command === "save" || command === "list" || command === "init") {
26
+ if (command === "version" || command === "--version" || command === "-v") {
27
+ return { name: "version" };
28
+ }
29
+ if (
30
+ command === "save" ||
31
+ command === "autosave" ||
32
+ command === "list" ||
33
+ command === "init" ||
34
+ command === "tag" ||
35
+ command === "update"
36
+ ) {
20
37
  return { name: command };
21
38
  }
39
+ if (isPrRef(command)) {
40
+ return { name: "resume-pr", prRef: command };
41
+ }
22
42
  return { name: "resume", branch: command };
23
43
  }
24
44
 
@@ -32,6 +52,10 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
32
52
  console.log((await saveSession()).message);
33
53
  return;
34
54
  }
55
+ if (selected.name === "autosave") {
56
+ console.log((await saveSession({ mode: "autosave" })).message);
57
+ return;
58
+ }
35
59
  if (selected.name === "list") {
36
60
  console.log((await listSessions()) || "No saved sessions");
37
61
  return;
@@ -40,8 +64,23 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
40
64
  console.log(await initRespawn());
41
65
  return;
42
66
  }
67
+ if (selected.name === "tag") {
68
+ console.log((await tagCurrentPr()).message);
69
+ return;
70
+ }
71
+ if (selected.name === "version") {
72
+ console.log(versionText());
73
+ return;
74
+ }
75
+ if (selected.name === "update") {
76
+ console.log(await updateRespawn());
77
+ return;
78
+ }
43
79
 
44
- const result = await resumeSession(selected.branch);
80
+ const result =
81
+ selected.name === "resume-pr"
82
+ ? await resumePrSession(selected.prRef)
83
+ : await resumeSession(selected.branch);
45
84
  const [cmd, ...cmdArgs] = result.command;
46
85
  const proc = Bun.spawn([cmd, ...cmdArgs], {
47
86
  stdin: "inherit",
@@ -55,12 +94,21 @@ function helpText(): string {
55
94
  return [
56
95
  "Usage:",
57
96
  " respawn save",
97
+ " respawn autosave",
98
+ " respawn tag",
58
99
  " respawn <branch>",
100
+ " respawn <pr-url|number>",
59
101
  " respawn list",
60
102
  " respawn init",
103
+ " respawn version",
104
+ " respawn update",
61
105
  ].join("\n");
62
106
  }
63
107
 
108
+ function isPrRef(value: string): boolean {
109
+ return /^\d+$/.test(value) || /github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(value);
110
+ }
111
+
64
112
  if (import.meta.main) {
65
113
  main().catch((error) => {
66
114
  console.error(error instanceof Error ? error.message : String(error));
@@ -22,10 +22,27 @@ export async function initRespawn(deps: InitDeps = {}): Promise<string> {
22
22
  const indexPath = deps.indexPath ?? defaultIndexPath(home);
23
23
  await writeIndex(indexPath, await readIndex(indexPath));
24
24
 
25
- const settingsPath = join(home, ".claude", "settings.json");
26
- const settings = await readSettings(settingsPath);
25
+ const command = "respawn autosave || true";
26
+ const claudePath = join(home, ".claude", "settings.json");
27
+ const codexPath = join(home, ".codex", "hooks.json");
28
+ await installStopHook(claudePath, command);
29
+ await installStopHook(codexPath, command);
30
+
31
+ return `Initialized respawn index at ${indexPath} and autosave Stop hooks at ${claudePath} and ${codexPath}`;
32
+ }
33
+
34
+ async function readSettings(path: string): Promise<ClaudeSettings> {
35
+ try {
36
+ return JSON.parse(await readFile(path, "utf8")) as ClaudeSettings;
37
+ } catch (error) {
38
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ async function installStopHook(path: string, command: string): Promise<void> {
44
+ const settings = await readSettings(path);
27
45
  const stopHooks = settings.hooks?.Stop ?? [];
28
- const command = "respawn save || true";
29
46
  const alreadyInstalled = stopHooks.some((group) =>
30
47
  group.hooks?.some((hook) => hook.command === command),
31
48
  );
@@ -35,19 +52,9 @@ export async function initRespawn(deps: InitDeps = {}): Promise<string> {
35
52
  matcher: "",
36
53
  hooks: [{ type: "command", command }],
37
54
  });
38
- settings.hooks = { ...settings.hooks, Stop: stopHooks };
39
- await mkdir(dirname(settingsPath), { recursive: true });
40
- await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
41
55
  }
42
56
 
43
- return `Initialized respawn index at ${indexPath} and Claude Stop hook at ${settingsPath}`;
44
- }
45
-
46
- async function readSettings(path: string): Promise<ClaudeSettings> {
47
- try {
48
- return JSON.parse(await readFile(path, "utf8")) as ClaudeSettings;
49
- } catch (error) {
50
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
51
- throw error;
52
- }
57
+ settings.hooks = { ...settings.hooks, Stop: stopHooks };
58
+ await mkdir(dirname(path), { recursive: true });
59
+ await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`);
53
60
  }
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { resumeCmd, targetTranscriptPath } from "../agents";
4
4
  import { checkoutBranch, currentRepo } from "../git";
5
+ import { checkoutPr, getRespawnTag } from "../github";
5
6
  import {
6
7
  defaultIndexPath,
7
8
  findLatestSession,
@@ -17,6 +18,14 @@ export type ResumeDeps = {
17
18
  targetTranscriptPath?: typeof targetTranscriptPath;
18
19
  };
19
20
 
21
+ export type ResumePrDeps = {
22
+ currentRepo?: typeof currentRepo;
23
+ getRespawnTag?: typeof getRespawnTag;
24
+ downloadGist?: typeof downloadGist;
25
+ checkoutPr?: typeof checkoutPr;
26
+ targetTranscriptPath?: typeof targetTranscriptPath;
27
+ };
28
+
20
29
  export async function resumeSession(
21
30
  branch: string,
22
31
  deps: ResumeDeps = {},
@@ -46,3 +55,37 @@ export async function resumeSession(
46
55
  session,
47
56
  };
48
57
  }
58
+
59
+ export async function resumePrSession(
60
+ prRef: string,
61
+ deps: ResumePrDeps = {},
62
+ ): Promise<{
63
+ command: string[];
64
+ path: string;
65
+ session: SavedSession;
66
+ }> {
67
+ const repo = await (deps.currentRepo ?? currentRepo)();
68
+ const tag = await (deps.getRespawnTag ?? getRespawnTag)(prRef);
69
+ if (!tag || tag.repo !== repo) {
70
+ throw new Error(`No respawn PR tag found for ${repo}#${prRef}`);
71
+ }
72
+
73
+ const session = [...tag.sessions]
74
+ .sort((a, b) => a.savedAt.localeCompare(b.savedAt))
75
+ .at(-1);
76
+ if (!session) {
77
+ throw new Error(`Respawn PR tag for ${repo}#${prRef} has no sessions`);
78
+ }
79
+
80
+ const transcript = await (deps.downloadGist ?? downloadGist)(session.gistUrl);
81
+ const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(session);
82
+ await mkdir(dirname(path), { recursive: true });
83
+ await writeFile(path, transcript);
84
+ await (deps.checkoutPr ?? checkoutPr)(prRef);
85
+
86
+ return {
87
+ command: resumeCmd(session.agent, session.sessionId),
88
+ path,
89
+ session,
90
+ };
91
+ }
@@ -1,7 +1,10 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
1
3
  import { locateActiveTranscript } from "../agents";
2
4
  import { currentBranch, currentRepo, currentSha } from "../git";
3
5
  import {
4
6
  defaultIndexPath,
7
+ readIndex,
5
8
  recordSession,
6
9
  type SavedSession,
7
10
  } from "../index-file";
@@ -9,6 +12,7 @@ import { createGist } from "../storage/gist";
9
12
 
10
13
  export type SaveDeps = {
11
14
  indexPath?: string;
15
+ mode?: "save" | "autosave";
12
16
  locateActiveTranscript?: typeof locateActiveTranscript;
13
17
  currentRepo?: typeof currentRepo;
14
18
  currentBranch?: typeof currentBranch;
@@ -19,6 +23,7 @@ export type SaveDeps = {
19
23
 
20
24
  export async function saveSession(deps: SaveDeps = {}): Promise<{
21
25
  message: string;
26
+ saved: boolean;
22
27
  session: SavedSession;
23
28
  }> {
24
29
  const locate = deps.locateActiveTranscript ?? locateActiveTranscript;
@@ -32,6 +37,37 @@ export async function saveSession(deps: SaveDeps = {}): Promise<{
32
37
  const repo = await (deps.currentRepo ?? currentRepo)();
33
38
  const branch = await (deps.currentBranch ?? currentBranch)();
34
39
  const sha = await (deps.currentSha ?? currentSha)();
40
+ const indexPath = deps.indexPath ?? defaultIndexPath();
41
+ const transcriptHash = await hashFile(transcript.path);
42
+
43
+ if (deps.mode === "autosave") {
44
+ const index = await readIndex(indexPath);
45
+ const unchanged = index.sessions.some(
46
+ (session) =>
47
+ session.repo === repo &&
48
+ session.branch === branch &&
49
+ session.agent === transcript.agent &&
50
+ session.sessionId === transcript.sessionId &&
51
+ session.transcriptHash === transcriptHash,
52
+ );
53
+ if (unchanged) {
54
+ return {
55
+ message: `No transcript changes to autosave for ${branch}`,
56
+ saved: false,
57
+ session: [...index.sessions]
58
+ .reverse()
59
+ .find(
60
+ (session) =>
61
+ session.repo === repo &&
62
+ session.branch === branch &&
63
+ session.agent === transcript.agent &&
64
+ session.sessionId === transcript.sessionId &&
65
+ session.transcriptHash === transcriptHash,
66
+ )!,
67
+ };
68
+ }
69
+ }
70
+
35
71
  const gistUrl = await (deps.createGist ?? createGist)(
36
72
  transcript.path,
37
73
  `respawn: ${repo}@${branch}`,
@@ -46,11 +82,17 @@ export async function saveSession(deps: SaveDeps = {}): Promise<{
46
82
  agent: transcript.agent,
47
83
  savedAt: (deps.now ?? (() => new Date()))().toISOString(),
48
84
  relativePath: transcript.relativePath,
85
+ transcriptHash,
49
86
  };
50
87
 
51
- await recordSession(deps.indexPath ?? defaultIndexPath(), session);
88
+ await recordSession(indexPath, session);
52
89
  return {
53
- message: `Saved ${session.agent} session ${session.sessionId} for ${branch}`,
90
+ message: `${deps.mode === "autosave" ? "Autosaved" : "Saved"} ${session.agent} session ${session.sessionId} for ${branch}`,
91
+ saved: true,
54
92
  session,
55
93
  };
56
94
  }
95
+
96
+ async function hashFile(path: string): Promise<string> {
97
+ return createHash("sha256").update(await readFile(path)).digest("hex");
98
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ currentPr,
3
+ getRespawnTag,
4
+ parseGitHubRepo,
5
+ upsertRespawnComment,
6
+ } from "../github";
7
+ import type { RespawnPrTag } from "../github";
8
+ import { saveSession } from "./save";
9
+
10
+ export type TagDeps = {
11
+ saveSession?: typeof saveSession;
12
+ currentPr?: typeof currentPr;
13
+ getRespawnTag?: typeof getRespawnTag;
14
+ upsertRespawnComment?: typeof upsertRespawnComment;
15
+ };
16
+
17
+ export async function tagCurrentPr(deps: TagDeps = {}): Promise<{
18
+ message: string;
19
+ tag: RespawnPrTag;
20
+ }> {
21
+ const saved = await (deps.saveSession ?? saveSession)();
22
+ const pr = await (deps.currentPr ?? currentPr)();
23
+ const repo = parseGitHubRepo(saved.session.repo);
24
+ const existing = await (deps.getRespawnTag ?? getRespawnTag)(String(pr.number));
25
+ const tag: RespawnPrTag = {
26
+ version: 1,
27
+ repo: saved.session.repo,
28
+ pr: pr.number,
29
+ branch: saved.session.branch,
30
+ sessions: [...(existing?.sessions ?? []), saved.session],
31
+ };
32
+
33
+ await (deps.upsertRespawnComment ?? upsertRespawnComment)({
34
+ owner: repo.owner,
35
+ name: repo.name,
36
+ pr: pr.number,
37
+ tag,
38
+ });
39
+
40
+ return {
41
+ message: `Tagged PR #${pr.number} with ${saved.session.agent} session ${saved.session.sessionId}`,
42
+ tag,
43
+ };
44
+ }
@@ -0,0 +1,26 @@
1
+ import packageJson from "../../package.json" with { type: "json" };
2
+ import { runCommand, type RunCommand } from "../shell";
3
+
4
+ export const currentVersion = packageJson.version;
5
+
6
+ export function versionText(version = currentVersion): string {
7
+ return `respawn-session ${version}`;
8
+ }
9
+
10
+ export type UpdateDeps = {
11
+ currentVersion?: string;
12
+ run?: RunCommand;
13
+ };
14
+
15
+ export async function updateRespawn(deps: UpdateDeps = {}): Promise<string> {
16
+ const version = deps.currentVersion ?? currentVersion;
17
+ const run = deps.run ?? runCommand;
18
+ const latest = (await run("npm", ["view", "respawn-session", "version"])).trim();
19
+
20
+ if (latest === version) {
21
+ return `respawn-session is already up to date at ${version}`;
22
+ }
23
+
24
+ await run("npm", ["install", "-g", "respawn-session@latest"]);
25
+ return `Updated respawn-session ${version} -> ${latest}`;
26
+ }
package/src/github.ts ADDED
@@ -0,0 +1,133 @@
1
+ import type { SavedSession } from "./index-file";
2
+ import { runCommand, type RunCommand } from "./shell";
3
+
4
+ const markerStart = "<!-- respawn-session";
5
+ const markerEnd = "-->";
6
+
7
+ export type GitHubRepo = {
8
+ owner: string;
9
+ name: string;
10
+ };
11
+
12
+ export type PrInfo = {
13
+ number: number;
14
+ url: string;
15
+ headRefName: string;
16
+ };
17
+
18
+ export type RespawnPrTag = {
19
+ version: 1;
20
+ repo: string;
21
+ pr: number;
22
+ branch: string;
23
+ sessions: SavedSession[];
24
+ };
25
+
26
+ type GhComment = {
27
+ id?: string;
28
+ body?: string;
29
+ };
30
+
31
+ export function parseGitHubRepo(remote: string): GitHubRepo {
32
+ const trimmed = remote.trim();
33
+ const ssh = trimmed.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
34
+ if (ssh) return { owner: ssh[1], name: ssh[2] };
35
+
36
+ const https = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
37
+ if (https) return { owner: https[1], name: https[2] };
38
+
39
+ throw new Error(`Unsupported GitHub remote: ${remote}`);
40
+ }
41
+
42
+ export function encodeRespawnComment(tag: RespawnPrTag): string {
43
+ return `${markerStart}\n${JSON.stringify(tag, null, 2)}\n${markerEnd}`;
44
+ }
45
+
46
+ export function decodeRespawnComment(body: string): RespawnPrTag | null {
47
+ const start = body.indexOf(markerStart);
48
+ if (start === -1) return null;
49
+ const jsonStart = start + markerStart.length;
50
+ const end = body.indexOf(markerEnd, jsonStart);
51
+ if (end === -1) return null;
52
+
53
+ try {
54
+ const parsed = JSON.parse(body.slice(jsonStart, end).trim()) as RespawnPrTag;
55
+ return parsed.version === 1 ? parsed : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export async function currentPr(run: RunCommand = runCommand): Promise<PrInfo> {
62
+ const raw = await run("gh", [
63
+ "pr",
64
+ "view",
65
+ "--json",
66
+ "number,url,headRefName",
67
+ ]);
68
+ const parsed = JSON.parse(raw) as PrInfo;
69
+ return parsed;
70
+ }
71
+
72
+ export async function getRespawnTag(
73
+ prRef: string,
74
+ run: RunCommand = runCommand,
75
+ ): Promise<RespawnPrTag | null> {
76
+ const raw = await run("gh", ["pr", "view", prRef, "--json", "comments"]);
77
+ const parsed = JSON.parse(raw) as { comments?: GhComment[] };
78
+ return findRespawnComment(parsed.comments ?? [])?.tag ?? null;
79
+ }
80
+
81
+ export async function upsertRespawnComment(
82
+ input: {
83
+ owner: string;
84
+ name: string;
85
+ pr: number;
86
+ tag: RespawnPrTag;
87
+ },
88
+ run: RunCommand = runCommand,
89
+ ): Promise<RespawnPrTag> {
90
+ const raw = await run("gh", [
91
+ "pr",
92
+ "view",
93
+ String(input.pr),
94
+ "--json",
95
+ "comments",
96
+ ]);
97
+ const parsed = JSON.parse(raw) as { comments?: GhComment[] };
98
+ const existing = findRespawnComment(parsed.comments ?? []);
99
+ const body = encodeRespawnComment(input.tag);
100
+
101
+ if (existing?.id) {
102
+ await run("gh", [
103
+ "api",
104
+ `repos/${input.owner}/${input.name}/issues/comments/${existing.id}`,
105
+ "-X",
106
+ "PATCH",
107
+ "-f",
108
+ `body=${body}`,
109
+ ]);
110
+ } else {
111
+ await run("gh", ["pr", "comment", String(input.pr), "--body", body]);
112
+ }
113
+
114
+ return input.tag;
115
+ }
116
+
117
+ export async function checkoutPr(
118
+ prRef: string,
119
+ run: RunCommand = runCommand,
120
+ ): Promise<void> {
121
+ await run("gh", ["pr", "checkout", prRef]);
122
+ }
123
+
124
+ function findRespawnComment(
125
+ comments: GhComment[],
126
+ ): { id?: string; tag: RespawnPrTag } | null {
127
+ for (const comment of comments) {
128
+ if (!comment.body) continue;
129
+ const tag = decodeRespawnComment(comment.body);
130
+ if (tag) return { id: comment.id, tag };
131
+ }
132
+ return null;
133
+ }
package/src/index-file.ts CHANGED
@@ -13,6 +13,7 @@ export type SavedSession = {
13
13
  agent: AgentName;
14
14
  savedAt: string;
15
15
  relativePath?: string;
16
+ transcriptHash?: string;
16
17
  };
17
18
 
18
19
  export type RespawnIndex = {