respawn-session 0.0.5 → 0.0.9

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
@@ -25,7 +25,9 @@ Initialize once on each machine. This creates `~/.respawn/index.json` and instal
25
25
  respawn init
26
26
  ```
27
27
 
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:
28
+ Work normally in Claude Code or Codex. `respawn init` makes sessions autosave when the agent stops. If the branch has a GitHub PR, autosave also updates a hidden PR comment so `respawn <pr-number>` works after the worktree or branch is gone.
29
+
30
+ To save immediately from inside an active agent session, run:
29
31
 
30
32
  ```sh
31
33
  respawn save
@@ -37,10 +39,11 @@ Resume the latest saved session for a branch:
37
39
  respawn angela/fix-bugs
38
40
  ```
39
41
 
40
- Resume from a PR that was tagged with `respawn tag`:
42
+ Resume from a PR that was autosaved or tagged:
41
43
 
42
44
  ```sh
43
45
  respawn 123
46
+ respawn internetbackyard/gnomos-app#514
44
47
  respawn https://github.com/org/repo/pull/123
45
48
  ```
46
49
 
@@ -80,25 +83,53 @@ After that, Claude Code and Codex Stop hooks run:
80
83
  respawn autosave
81
84
  ```
82
85
 
83
- Autosave hashes the transcript and skips unchanged sessions, so repeated Stop events do not create duplicate gists.
86
+ Autosave hashes the transcript and skips unchanged sessions, so repeated Stop events do not create duplicate gists. When the current branch has a GitHub PR, autosave also writes or updates the hidden respawn PR comment. That is the normal path for:
87
+
88
+ ```sh
89
+ respawn 517
90
+ ```
91
+
92
+ after you delete the worktree.
84
93
 
85
94
  ### PR Tagging
86
95
 
87
- Use this when you want a session to survive branch deletion after merge:
96
+ Use this when you want to force-save and attach the current session to the current PR manually:
88
97
 
89
98
  ```sh
90
99
  respawn tag
91
100
  ```
92
101
 
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.
102
+ That writes or updates the same 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
103
 
95
104
  Later, resume from the PR:
96
105
 
97
106
  ```sh
98
107
  respawn 123
108
+ respawn internetbackyard/gnomos-app#514
99
109
  respawn https://github.com/org/repo/pull/123
100
110
  ```
101
111
 
112
+ ### Link Imported Sessions To PRs
113
+
114
+ This is for old sessions from before autosave tagged PRs automatically. After `respawn import`, sync saved sessions to matching PRs in a repo:
115
+
116
+ ```sh
117
+ respawn import internetbackyard/gnomos-app
118
+ respawn link internetbackyard/gnomos-app --dry-run
119
+ respawn link internetbackyard/gnomos-app
120
+ ```
121
+
122
+ Link matches sessions to PRs by branch name first, then by PR head SHA when available. It only writes PR metadata comments; it does not upload transcripts.
123
+
124
+ Always run the dry-run first. It prints the exact PRs it would touch:
125
+
126
+ ```sh
127
+ Would link 1 PRs in internetbackyard/gnomos-app; 0 sessions unmatched
128
+ #514 feat/int-1194-tool-actor-context (1 session)
129
+ ```
130
+
131
+ If the PR you want is not listed, `respawn` does not have enough local evidence to link it automatically yet. The usual cause is an old transcript from a deleted worktree that has not been imported with `respawn import owner/repo`.
132
+
102
133
  ### List Saved Sessions
103
134
 
104
135
  Show every saved session in your local index:
@@ -117,18 +148,33 @@ respawn import
117
148
 
118
149
  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
150
 
151
+ If the worktree was deleted, give `respawn` the repo explicitly:
152
+
153
+ ```sh
154
+ respawn import internetbackyard/gnomos-app
155
+ ```
156
+
157
+ For deleted worktrees, `respawn` can still import Claude Code project transcripts when the original cwd contains the repo name and the transcript has embedded branch metadata. Those imported rows use `sha: "unknown"`, but `respawn link` can still match them to PRs by branch.
158
+
120
159
  ## Commands
121
160
 
122
161
  | Command | What it does |
123
162
  | --- | --- |
124
163
  | `respawn init` | Creates the local index and installs autosave hooks |
125
164
  | `respawn save` | Saves the active Claude Code or Codex transcript |
126
- | `respawn autosave` | Saves only if the transcript changed |
165
+ | `respawn autosave` | Saves only if the transcript changed and tags the current PR when one exists |
127
166
  | `respawn tag` | Saves and attaches session metadata to the current PR |
128
167
  | `respawn import` | Backfills existing local Claude Code and Codex sessions |
168
+ | `respawn import owner/repo` | Backfills deleted-worktree transcripts for a repo when branch metadata exists |
169
+ | `respawn link owner/repo` | Links imported sessions to matching PRs |
170
+ | `respawn link owner/repo --dry-run` | Previews PR links without writing comments |
129
171
  | `respawn <branch>` | Restores the newest session for a branch |
172
+ | `respawn owner/repo:branch` | Restores a branch session without being in that repo |
173
+ | `respawn --repo owner/repo <branch>` | Restores a branch session for an explicit repo |
130
174
  | `respawn <pr-number>` | Restores the newest session from a tagged PR |
175
+ | `respawn owner/repo#123` | Restores a tagged PR without being in that repo |
131
176
  | `respawn <pr-url>` | Restores the newest session from a tagged PR URL |
177
+ | `respawn --repo owner/repo 123` | Restores a tagged PR for an explicit repo |
132
178
  | `respawn list` | Lists locally indexed sessions |
133
179
  | `respawn version` | Prints the installed CLI version |
134
180
  | `respawn update` | Updates the global npm install to the latest release |
@@ -143,7 +189,7 @@ npm install -g respawn-session@latest
143
189
 
144
190
  `respawn save` detects the active agent in this order:
145
191
 
146
- 1. Claude Code via `CLAUDE_SESSION_ID`, then `~/.claude/sessions/*.json`
192
+ 1. Claude Code via `CLAUDE_SESSION_ID`, then `~/.claude/sessions/*.json` and `~/.claude/projects/**/*.jsonl`
147
193
  2. Codex via `CODEX_TUI_SESSION_LOG_PATH`, `CODEX_SESSION_ID`, or the newest `~/.codex/sessions/**.jsonl` transcript for the current cwd
148
194
 
149
195
  It then runs:
@@ -162,6 +208,10 @@ The local index lives at:
162
208
 
163
209
  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.
164
210
 
211
+ `respawn autosave` is what makes the main workflow work: it saves the transcript and, when `gh pr view` can resolve the current branch's PR, writes the session pointer to that PR. Later `respawn <pr-number>` reads that pointer, restores the transcript, checks out the PR, and resumes the agent.
212
+
213
+ `respawn import owner/repo` exists for old deleted worktrees. It scans local transcripts whose recorded cwd contains that repo name and imports the ones with embedded branch metadata. This is best-effort recovery for sessions that were created before `respawn` was installed.
214
+
165
215
  `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.
166
216
 
167
217
  ## Agent Paths
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "respawn-session",
3
- "version": "0.0.5",
3
+ "version": "0.0.9",
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, readdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import type { ImportableTranscript, LocatedTranscript, LocateOptions } from "./types";
5
5
 
6
6
  export function encodeClaudeProjectPath(cwd: string): string {
7
- return cwd.replace(/[^A-Za-z0-9._-]/g, "-");
7
+ return cwd.replace(/[^A-Za-z0-9_-]/g, "-");
8
8
  }
9
9
 
10
10
  export function transcriptPath(
@@ -41,10 +41,11 @@ export function resumeCmd(sessionId: string): string[] {
41
41
 
42
42
  export function listTranscripts(options: LocateOptions = {}): ImportableTranscript[] {
43
43
  const home = options.home ?? homedir();
44
+ const projectTranscripts = listProjectTranscripts(home);
44
45
  const sessionsDir = join(home, ".claude", "sessions");
45
- if (!existsSync(sessionsDir)) return [];
46
+ if (!existsSync(sessionsDir)) return projectTranscripts;
46
47
 
47
- return readdirSync(sessionsDir, { withFileTypes: true })
48
+ const registryTranscripts = readdirSync(sessionsDir, { withFileTypes: true })
48
49
  .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
49
50
  .map((entry) => readSessionRecord(join(sessionsDir, entry.name)))
50
51
  .filter(
@@ -68,6 +69,11 @@ export function listTranscripts(options: LocateOptions = {}): ImportableTranscri
68
69
  };
69
70
  })
70
71
  .filter((transcript): transcript is ImportableTranscript => transcript !== null);
72
+
73
+ return uniqueByPath([
74
+ ...projectTranscripts,
75
+ ...registryTranscripts,
76
+ ]);
71
77
  }
72
78
 
73
79
  type ClaudeSessionRecord = {
@@ -76,6 +82,45 @@ type ClaudeSessionRecord = {
76
82
  updatedAt?: number;
77
83
  };
78
84
 
85
+ type ClaudeProjectMeta = {
86
+ sessionId?: string;
87
+ cwd?: string;
88
+ gitBranch?: string;
89
+ timestamp?: string;
90
+ };
91
+
92
+ function listProjectTranscripts(home: string): ImportableTranscript[] {
93
+ const projectsDir = join(home, ".claude", "projects");
94
+ if (!existsSync(projectsDir)) return [];
95
+
96
+ return readdirSync(projectsDir, { withFileTypes: true })
97
+ .filter((entry) => entry.isDirectory())
98
+ .flatMap((project) =>
99
+ readdirSync(join(projectsDir, project.name), { withFileTypes: true })
100
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
101
+ .map((entry) => join(projectsDir, project.name, entry.name)),
102
+ )
103
+ .map((path): ImportableTranscript | null => {
104
+ const meta = readProjectTranscriptMeta(path);
105
+ const sessionId = meta?.sessionId ?? basename(path, ".jsonl");
106
+ if (!sessionId || !meta?.cwd) return null;
107
+ return {
108
+ agent: "claude" as const,
109
+ path,
110
+ sessionId,
111
+ cwd: meta.cwd,
112
+ branch:
113
+ meta.gitBranch && meta.gitBranch !== "HEAD"
114
+ ? meta.gitBranch
115
+ : undefined,
116
+ savedAt: meta.timestamp
117
+ ? new Date(meta.timestamp).toISOString()
118
+ : statSync(path).mtime.toISOString(),
119
+ };
120
+ })
121
+ .filter((transcript): transcript is ImportableTranscript => transcript !== null);
122
+ }
123
+
79
124
  function locateFromSessionRegistry(
80
125
  options: LocateOptions = {},
81
126
  ): LocatedTranscript | null {
@@ -110,3 +155,31 @@ function readSessionRecord(path: string): ClaudeSessionRecord | null {
110
155
  return null;
111
156
  }
112
157
  }
158
+
159
+ function readProjectTranscriptMeta(path: string): ClaudeProjectMeta | null {
160
+ try {
161
+ const lines = readFileSync(path, "utf8").split("\n").slice(0, 100);
162
+ const meta: ClaudeProjectMeta = {};
163
+ for (const line of lines) {
164
+ if (!line.trim()) continue;
165
+ const parsed = JSON.parse(line) as ClaudeProjectMeta;
166
+ meta.sessionId ??= parsed.sessionId;
167
+ meta.cwd ??= parsed.cwd;
168
+ meta.gitBranch ??= parsed.gitBranch;
169
+ meta.timestamp ??= parsed.timestamp;
170
+ if (meta.cwd && meta.sessionId && meta.gitBranch) return meta;
171
+ }
172
+ return meta.cwd ? meta : null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ function uniqueByPath(transcripts: ImportableTranscript[]): ImportableTranscript[] {
179
+ const seen = new Set<string>();
180
+ return transcripts.filter((transcript) => {
181
+ if (seen.has(transcript.path)) return false;
182
+ seen.add(transcript.path);
183
+ return true;
184
+ });
185
+ }
@@ -9,6 +9,7 @@ export type LocatedTranscript = {
9
9
 
10
10
  export type ImportableTranscript = LocatedTranscript & {
11
11
  cwd: string;
12
+ branch?: string;
12
13
  savedAt?: string;
13
14
  };
14
15
 
package/src/cli.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { initRespawn } from "./commands/init";
3
+ import { autosaveSession } from "./commands/autosave";
3
4
  import { importSessions } from "./commands/import";
5
+ import { linkRepo } from "./commands/link";
4
6
  import { listSessions } from "./commands/list";
5
7
  import { resumePrSession, resumeSession } from "./commands/resume";
6
8
  import { saveSession } from "./commands/save";
@@ -13,36 +15,51 @@ export type Route =
13
15
  | { name: "autosave" }
14
16
  | { name: "list" }
15
17
  | { name: "init" }
16
- | { name: "import" }
18
+ | { name: "import"; repo?: string }
19
+ | { name: "link"; repo: string; dryRun: boolean }
17
20
  | { name: "tag" }
18
21
  | { name: "version" }
19
22
  | { name: "update" }
20
- | { name: "resume"; branch: string }
21
- | { name: "resume-pr"; prRef: string };
23
+ | { name: "resume"; branch: string; repo?: string }
24
+ | { name: "resume-pr"; prRef: string; repo?: string };
22
25
 
23
26
  export function route(args: string[]): Route {
24
- const [command] = args;
27
+ const { repo, rest } = parseGlobalOptions(args);
28
+ const [command] = rest;
25
29
  if (!command || command === "help" || command === "--help" || command === "-h") {
26
30
  return { name: "help" };
27
31
  }
28
32
  if (command === "version" || command === "--version" || command === "-v") {
29
33
  return { name: "version" };
30
34
  }
35
+ if (command === "link") {
36
+ const linkArgs = rest.slice(1).filter((arg) => arg !== "--dry-run");
37
+ return {
38
+ name: "link",
39
+ repo: linkArgs[0] ?? "",
40
+ dryRun: rest.includes("--dry-run"),
41
+ };
42
+ }
31
43
  if (
32
44
  command === "save" ||
33
45
  command === "autosave" ||
34
46
  command === "list" ||
35
47
  command === "init" ||
36
- command === "import" ||
37
48
  command === "tag" ||
38
49
  command === "update"
39
50
  ) {
40
51
  return { name: command };
41
52
  }
53
+ if (command === "import") {
54
+ return {
55
+ name: "import",
56
+ repo: repo ?? rest.slice(1).find((arg) => !arg.startsWith("-")),
57
+ };
58
+ }
42
59
  if (isPrRef(command)) {
43
- return { name: "resume-pr", prRef: command };
60
+ return parsePrRoute(command, repo);
44
61
  }
45
- return { name: "resume", branch: command };
62
+ return parseBranchRoute(command, repo);
46
63
  }
47
64
 
48
65
  export async function main(args = Bun.argv.slice(2)): Promise<void> {
@@ -56,7 +73,7 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
56
73
  return;
57
74
  }
58
75
  if (selected.name === "autosave") {
59
- console.log((await saveSession({ mode: "autosave" })).message);
76
+ console.log((await autosaveSession()).message);
60
77
  return;
61
78
  }
62
79
  if (selected.name === "list") {
@@ -68,7 +85,14 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
68
85
  return;
69
86
  }
70
87
  if (selected.name === "import") {
71
- console.log((await importSessions()).message);
88
+ console.log((await importSessions({ repo: selected.repo })).message);
89
+ return;
90
+ }
91
+ if (selected.name === "link") {
92
+ if (!selected.repo) throw new Error("Usage: respawn link owner/repo [--dry-run]");
93
+ console.log(
94
+ (await linkRepo(selected.repo, { dryRun: selected.dryRun })).message,
95
+ );
72
96
  return;
73
97
  }
74
98
  if (selected.name === "tag") {
@@ -86,8 +110,8 @@ export async function main(args = Bun.argv.slice(2)): Promise<void> {
86
110
 
87
111
  const result =
88
112
  selected.name === "resume-pr"
89
- ? await resumePrSession(selected.prRef)
90
- : await resumeSession(selected.branch);
113
+ ? await resumePrSession(selected.prRef, { repo: selected.repo })
114
+ : await resumeSession(selected.branch, { repo: selected.repo });
91
115
  const [cmd, ...cmdArgs] = result.command;
92
116
  const proc = Bun.spawn([cmd, ...cmdArgs], {
93
117
  stdin: "inherit",
@@ -105,16 +129,52 @@ function helpText(): string {
105
129
  " respawn tag",
106
130
  " respawn <branch>",
107
131
  " respawn <pr-url|number>",
132
+ " respawn owner/repo:branch",
133
+ " respawn owner/repo#123",
134
+ " respawn --repo owner/repo <branch|number>",
108
135
  " respawn list",
109
136
  " respawn init",
110
137
  " respawn import",
138
+ " respawn import owner/repo",
139
+ " respawn link owner/repo [--dry-run]",
111
140
  " respawn version",
112
141
  " respawn update",
113
142
  ].join("\n");
114
143
  }
115
144
 
116
145
  function isPrRef(value: string): boolean {
117
- return /^\d+$/.test(value) || /github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(value);
146
+ return (
147
+ /^\d+$/.test(value) ||
148
+ /^[^/\s]+\/[^#:\s]+#\d+$/.test(value) ||
149
+ /github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(value)
150
+ );
151
+ }
152
+
153
+ function parseGlobalOptions(args: string[]): { repo?: string; rest: string[] } {
154
+ if (args[0] === "--repo") {
155
+ return { repo: args[1], rest: args.slice(2) };
156
+ }
157
+ return { rest: args };
158
+ }
159
+
160
+ function parsePrRoute(value: string, repo?: string): Route {
161
+ const url = value.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)$/);
162
+ if (url) return { name: "resume-pr", repo: url[1], prRef: url[2] };
163
+
164
+ const qualified = value.match(/^([^/\s]+\/[^#:\s]+)#(\d+)$/);
165
+ if (qualified) {
166
+ return { name: "resume-pr", repo: qualified[1], prRef: qualified[2] };
167
+ }
168
+
169
+ return { name: "resume-pr", prRef: value, repo };
170
+ }
171
+
172
+ function parseBranchRoute(value: string, repo?: string): Route {
173
+ const qualified = value.match(/^([^/\s]+\/[^#:\s]+):(.+)$/);
174
+ if (qualified) {
175
+ return { name: "resume", repo: qualified[1], branch: qualified[2] };
176
+ }
177
+ return { name: "resume", branch: value, repo };
118
178
  }
119
179
 
120
180
  if (import.meta.main) {
@@ -0,0 +1,100 @@
1
+ import {
2
+ currentPr,
3
+ getRespawnTag,
4
+ parseGitHubRepo,
5
+ upsertRespawnComment,
6
+ } from "../github";
7
+ import type { RespawnPrTag } from "../github";
8
+ import { saveSession, type SaveDeps, type SaveResult } from "./save";
9
+
10
+ export type AutosaveDeps = SaveDeps & {
11
+ saveSession?: typeof saveSession;
12
+ currentPr?: typeof currentPr;
13
+ getRespawnTag?: typeof getRespawnTag;
14
+ upsertRespawnComment?: typeof upsertRespawnComment;
15
+ };
16
+
17
+ export type AutosaveResult = SaveResult & {
18
+ tag?: RespawnPrTag;
19
+ };
20
+
21
+ export async function autosaveSession(
22
+ deps: AutosaveDeps = {},
23
+ ): Promise<AutosaveResult> {
24
+ const {
25
+ saveSession: save,
26
+ currentPr: current,
27
+ getRespawnTag: getTag,
28
+ upsertRespawnComment: upsert,
29
+ ...saveDeps
30
+ } = deps;
31
+
32
+ const saved = await (save ?? saveSession)({
33
+ ...saveDeps,
34
+ mode: "autosave",
35
+ });
36
+
37
+ const pr = await maybeCurrentPr(current ?? currentPr);
38
+ if (!pr) return saved;
39
+
40
+ try {
41
+ const repo = parseGitHubRepo(saved.session.repo);
42
+ const repoName = `${repo.owner}/${repo.name}`;
43
+ const existing = await (getTag ?? getRespawnTag)(String(pr.number), repoName);
44
+ const sessions = appendSession(existing?.sessions ?? [], saved.session);
45
+ const tag: RespawnPrTag = {
46
+ version: 1,
47
+ repo: repoName,
48
+ pr: pr.number,
49
+ branch: pr.headRefName,
50
+ sessions,
51
+ };
52
+
53
+ await (upsert ?? upsertRespawnComment)({
54
+ owner: repo.owner,
55
+ name: repo.name,
56
+ pr: pr.number,
57
+ tag,
58
+ });
59
+
60
+ return {
61
+ ...saved,
62
+ message: `${saved.message}; tagged PR #${pr.number} with session ${saved.session.sessionId}`,
63
+ tag,
64
+ };
65
+ } catch (error) {
66
+ return {
67
+ ...saved,
68
+ message: `${saved.message}; PR tag failed: ${errorMessage(error)}`,
69
+ };
70
+ }
71
+ }
72
+
73
+ async function maybeCurrentPr(
74
+ detect: typeof currentPr,
75
+ ): Promise<Awaited<ReturnType<typeof currentPr>> | null> {
76
+ try {
77
+ return await detect();
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function appendSession(
84
+ sessions: RespawnPrTag["sessions"],
85
+ session: RespawnPrTag["sessions"][number],
86
+ ): RespawnPrTag["sessions"] {
87
+ const key = sessionKey(session);
88
+ if (sessions.some((existing) => sessionKey(existing) === key)) {
89
+ return sessions;
90
+ }
91
+ return [...sessions, session];
92
+ }
93
+
94
+ function sessionKey(session: RespawnPrTag["sessions"][number]): string {
95
+ return `${session.agent}:${session.sessionId}:${session.gistUrl}`;
96
+ }
97
+
98
+ function errorMessage(error: unknown): string {
99
+ return error instanceof Error ? error.message : String(error);
100
+ }
@@ -10,6 +10,7 @@ import {
10
10
  type SavedSession,
11
11
  } from "../index-file";
12
12
  import { createGist } from "../storage/gist";
13
+ import { parseGitHubRepo } from "../github";
13
14
 
14
15
  export type ImportResult = {
15
16
  imported: number;
@@ -20,6 +21,7 @@ export type ImportResult = {
20
21
 
21
22
  export type ImportDeps = {
22
23
  indexPath?: string;
24
+ repo?: string;
23
25
  listTranscripts?: typeof listAllTranscripts;
24
26
  gitInfoForCwd?: typeof gitInfoForCwd;
25
27
  createGist?: typeof createGist;
@@ -38,7 +40,9 @@ export async function importSessions(
38
40
  let skipped = 0;
39
41
 
40
42
  for (const transcript of transcripts) {
41
- const info = await gitInfo(transcript.cwd);
43
+ const info =
44
+ (await gitInfo(transcript.cwd)) ??
45
+ fallbackGitInfo(transcript, deps.repo);
42
46
  if (!info) {
43
47
  skipped += 1;
44
48
  continue;
@@ -77,6 +81,29 @@ export async function importSessions(
77
81
  };
78
82
  }
79
83
 
84
+ function fallbackGitInfo(
85
+ transcript: ImportableTranscript,
86
+ repo?: string,
87
+ ): GitInfo | null {
88
+ if (!repo || !transcript.branch || !cwdLooksLikeRepo(transcript.cwd, repo)) {
89
+ return null;
90
+ }
91
+ return {
92
+ repo,
93
+ branch: transcript.branch,
94
+ sha: "unknown",
95
+ };
96
+ }
97
+
98
+ function cwdLooksLikeRepo(cwd: string, repo: string): boolean {
99
+ try {
100
+ const repoName = parseGitHubRepo(repo).name;
101
+ return cwd.split(/[\\/]/).includes(repoName);
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
80
107
  async function isDuplicate(
81
108
  indexPath: string,
82
109
  info: GitInfo,
@@ -0,0 +1,109 @@
1
+ import {
2
+ listPullRequests,
3
+ parseGitHubRepo,
4
+ repoKey,
5
+ upsertRespawnComment,
6
+ type PrInfo,
7
+ type RespawnPrTag,
8
+ } from "../github";
9
+ import {
10
+ defaultIndexPath,
11
+ readIndex,
12
+ type SavedSession,
13
+ } from "../index-file";
14
+
15
+ export type LinkResult = {
16
+ linked: number;
17
+ dryRun: boolean;
18
+ unmatchedSessions: number;
19
+ message: string;
20
+ };
21
+
22
+ export type LinkDeps = {
23
+ indexPath?: string;
24
+ dryRun?: boolean;
25
+ listPullRequests?: typeof listPullRequests;
26
+ upsertRespawnComment?: typeof upsertRespawnComment;
27
+ };
28
+
29
+ export async function linkRepo(
30
+ repo: string,
31
+ deps: LinkDeps = {},
32
+ ): Promise<LinkResult> {
33
+ const index = await readIndex(deps.indexPath ?? defaultIndexPath());
34
+ const key = repoKey(repo);
35
+ const sessions = index.sessions.filter((session) => safeRepoKey(session.repo) === key);
36
+ const prs = await (deps.listPullRequests ?? listPullRequests)(key);
37
+ const used = new Set<SavedSession>();
38
+ const details: string[] = [];
39
+ let linked = 0;
40
+
41
+ for (const pr of prs) {
42
+ const matches = uniqueSessions(
43
+ sessions.filter((session) => sessionMatchesPr(session, pr)),
44
+ );
45
+ if (matches.length === 0) continue;
46
+
47
+ for (const session of matches) used.add(session);
48
+ linked += 1;
49
+ details.push(
50
+ ` #${pr.number} ${pr.headRefName} (${matches.length} ${plural(matches.length, "session")})`,
51
+ );
52
+
53
+ if (!deps.dryRun) {
54
+ const parsed = parseGitHubRepo(key);
55
+ await (deps.upsertRespawnComment ?? upsertRespawnComment)({
56
+ owner: parsed.owner,
57
+ name: parsed.name,
58
+ pr: pr.number,
59
+ tag: {
60
+ version: 1,
61
+ repo: key,
62
+ pr: pr.number,
63
+ branch: pr.headRefName,
64
+ sessions: matches.sort((a, b) => a.savedAt.localeCompare(b.savedAt)),
65
+ },
66
+ });
67
+ }
68
+ }
69
+
70
+ const unmatchedSessions = sessions.filter((session) => !used.has(session)).length;
71
+ const prefix = deps.dryRun ? "Would link" : "Linked";
72
+ return {
73
+ linked,
74
+ dryRun: Boolean(deps.dryRun),
75
+ unmatchedSessions,
76
+ message: [
77
+ `${prefix} ${linked} PRs in ${key}; ${unmatchedSessions} ${plural(unmatchedSessions, "session")} unmatched`,
78
+ ...details,
79
+ ].join("\n"),
80
+ };
81
+ }
82
+
83
+ function sessionMatchesPr(session: SavedSession, pr: PrInfo): boolean {
84
+ if (session.branch === pr.headRefName) return true;
85
+ if (pr.headRefOid && session.sha === pr.headRefOid) return true;
86
+ return Boolean(pr.commits?.some((commit) => commit.oid === session.sha));
87
+ }
88
+
89
+ function uniqueSessions(sessions: SavedSession[]): SavedSession[] {
90
+ const seen = new Set<string>();
91
+ return sessions.filter((session) => {
92
+ const key = `${session.agent}:${session.sessionId}:${session.gistUrl}`;
93
+ if (seen.has(key)) return false;
94
+ seen.add(key);
95
+ return true;
96
+ });
97
+ }
98
+
99
+ function safeRepoKey(repo: string): string | null {
100
+ try {
101
+ return repoKey(repo);
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function plural(count: number, noun: string): string {
108
+ return `${noun}${count === 1 ? "" : "s"}`;
109
+ }
@@ -2,7 +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
+ import { checkoutPr, getRespawnTag, repoKey } from "../github";
6
6
  import {
7
7
  defaultIndexPath,
8
8
  findLatestSession,
@@ -12,6 +12,7 @@ import { downloadGist } from "../storage/gist";
12
12
 
13
13
  export type ResumeDeps = {
14
14
  indexPath?: string;
15
+ repo?: string;
15
16
  currentRepo?: typeof currentRepo;
16
17
  downloadGist?: typeof downloadGist;
17
18
  checkoutBranch?: typeof checkoutBranch;
@@ -19,6 +20,7 @@ export type ResumeDeps = {
19
20
  };
20
21
 
21
22
  export type ResumePrDeps = {
23
+ repo?: string;
22
24
  currentRepo?: typeof currentRepo;
23
25
  getRespawnTag?: typeof getRespawnTag;
24
26
  downloadGist?: typeof downloadGist;
@@ -34,25 +36,28 @@ export async function resumeSession(
34
36
  path: string;
35
37
  session: SavedSession;
36
38
  }> {
37
- const repo = await (deps.currentRepo ?? currentRepo)();
39
+ const repo = deps.repo ?? (await (deps.currentRepo ?? currentRepo)());
38
40
  const session = await findLatestSession(deps.indexPath ?? defaultIndexPath(), {
39
- repo,
41
+ repo: repo,
40
42
  branch,
41
43
  });
42
- if (!session) {
44
+ const matchedSession =
45
+ session ??
46
+ (await findLatestByRepoKey(deps.indexPath ?? defaultIndexPath(), repo, branch));
47
+ if (!matchedSession) {
43
48
  throw new Error(`No saved respawn session found for ${repo}@${branch}`);
44
49
  }
45
50
 
46
- const transcript = await (deps.downloadGist ?? downloadGist)(session.gistUrl);
47
- const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(session);
51
+ const transcript = await (deps.downloadGist ?? downloadGist)(matchedSession.gistUrl);
52
+ const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(matchedSession);
48
53
  await mkdir(dirname(path), { recursive: true });
49
54
  await writeFile(path, transcript);
50
55
  await (deps.checkoutBranch ?? checkoutBranch)(branch);
51
56
 
52
57
  return {
53
- command: resumeCmd(session.agent, session.sessionId),
58
+ command: resumeCmd(matchedSession.agent, matchedSession.sessionId),
54
59
  path,
55
- session,
60
+ session: matchedSession,
56
61
  };
57
62
  }
58
63
 
@@ -64,9 +69,9 @@ export async function resumePrSession(
64
69
  path: string;
65
70
  session: SavedSession;
66
71
  }> {
67
- const repo = await (deps.currentRepo ?? currentRepo)();
68
- const tag = await (deps.getRespawnTag ?? getRespawnTag)(prRef);
69
- if (!tag || tag.repo !== repo) {
72
+ const repo = deps.repo ?? (await (deps.currentRepo ?? currentRepo)());
73
+ const tag = await (deps.getRespawnTag ?? getRespawnTag)(prRef, deps.repo);
74
+ if (!tag || !reposMatch(tag.repo, repo)) {
70
75
  throw new Error(`No respawn PR tag found for ${repo}#${prRef}`);
71
76
  }
72
77
 
@@ -81,7 +86,7 @@ export async function resumePrSession(
81
86
  const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(session);
82
87
  await mkdir(dirname(path), { recursive: true });
83
88
  await writeFile(path, transcript);
84
- await (deps.checkoutPr ?? checkoutPr)(prRef);
89
+ await (deps.checkoutPr ?? checkoutPr)(prRef, deps.repo);
85
90
 
86
91
  return {
87
92
  command: resumeCmd(session.agent, session.sessionId),
@@ -89,3 +94,37 @@ export async function resumePrSession(
89
94
  session,
90
95
  };
91
96
  }
97
+
98
+ async function findLatestByRepoKey(
99
+ indexPath: string,
100
+ repo: string,
101
+ branch: string,
102
+ ): Promise<SavedSession | null> {
103
+ const { readIndex } = await import("../index-file");
104
+ const key = repoKey(repo);
105
+ const index = await readIndex(indexPath);
106
+ return (
107
+ index.sessions
108
+ .filter(
109
+ (session) =>
110
+ session.branch === branch && safeRepoKey(session.repo) === key,
111
+ )
112
+ .sort((a, b) => a.savedAt.localeCompare(b.savedAt))
113
+ .at(-1) ?? null
114
+ );
115
+ }
116
+
117
+ function safeRepoKey(repo: string): string | null {
118
+ try {
119
+ return repoKey(repo);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function reposMatch(a: string, b: string): boolean {
126
+ if (a === b) return true;
127
+ const aKey = safeRepoKey(a);
128
+ const bKey = safeRepoKey(b);
129
+ return Boolean(aKey && bKey && aKey === bKey);
130
+ }
@@ -21,11 +21,13 @@ export type SaveDeps = {
21
21
  now?: () => Date;
22
22
  };
23
23
 
24
- export async function saveSession(deps: SaveDeps = {}): Promise<{
24
+ export type SaveResult = {
25
25
  message: string;
26
26
  saved: boolean;
27
27
  session: SavedSession;
28
- }> {
28
+ };
29
+
30
+ export async function saveSession(deps: SaveDeps = {}): Promise<SaveResult> {
29
31
  const locate = deps.locateActiveTranscript ?? locateActiveTranscript;
30
32
  const transcript = locate();
31
33
  if (!transcript) {
package/src/github.ts CHANGED
@@ -13,6 +13,10 @@ export type PrInfo = {
13
13
  number: number;
14
14
  url: string;
15
15
  headRefName: string;
16
+ headRefOid?: string;
17
+ state?: string;
18
+ title?: string;
19
+ commits?: Array<{ oid: string }>;
16
20
  };
17
21
 
18
22
  export type RespawnPrTag = {
@@ -30,15 +34,32 @@ type GhComment = {
30
34
 
31
35
  export function parseGitHubRepo(remote: string): GitHubRepo {
32
36
  const trimmed = remote.trim();
37
+ const prUrl = trimmed.match(
38
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+$/,
39
+ );
40
+ if (prUrl) return { owner: prUrl[1], name: prUrl[2].replace(/\.git$/, "") };
41
+
33
42
  const ssh = trimmed.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
34
43
  if (ssh) return { owner: ssh[1], name: ssh[2] };
35
44
 
36
45
  const https = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
37
46
  if (https) return { owner: https[1], name: https[2] };
38
47
 
48
+ const short = trimmed.match(/^([^/\s]+)\/([^/\s]+)$/);
49
+ if (short) return { owner: short[1], name: short[2].replace(/\.git$/, "") };
50
+
39
51
  throw new Error(`Unsupported GitHub remote: ${remote}`);
40
52
  }
41
53
 
54
+ export function repoKey(remote: string): string {
55
+ const repo = parseGitHubRepo(remote);
56
+ return `${repo.owner}/${repo.name}`;
57
+ }
58
+
59
+ export function prNumberFromRef(prRef: string): string {
60
+ return prRef.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/)?.[1] ?? prRef;
61
+ }
62
+
42
63
  export function encodeRespawnComment(tag: RespawnPrTag): string {
43
64
  return `${markerStart}\n${JSON.stringify(tag, null, 2)}\n${markerEnd}`;
44
65
  }
@@ -69,11 +90,33 @@ export async function currentPr(run: RunCommand = runCommand): Promise<PrInfo> {
69
90
  return parsed;
70
91
  }
71
92
 
93
+ export async function listPullRequests(
94
+ repo: string,
95
+ run: RunCommand = runCommand,
96
+ ): Promise<PrInfo[]> {
97
+ const raw = await run("gh", [
98
+ "pr",
99
+ "list",
100
+ "--repo",
101
+ repo,
102
+ "--state",
103
+ "all",
104
+ "--limit",
105
+ "1000",
106
+ "--json",
107
+ "number,url,headRefName,headRefOid,state,title",
108
+ ]);
109
+ return JSON.parse(raw) as PrInfo[];
110
+ }
111
+
72
112
  export async function getRespawnTag(
73
113
  prRef: string,
114
+ repo?: string,
74
115
  run: RunCommand = runCommand,
75
116
  ): Promise<RespawnPrTag | null> {
76
- const raw = await run("gh", ["pr", "view", prRef, "--json", "comments"]);
117
+ const args = ["pr", "view", prNumberFromRef(prRef), "--json", "comments"];
118
+ if (repo) args.push("--repo", repo);
119
+ const raw = await run("gh", args);
77
120
  const parsed = JSON.parse(raw) as { comments?: GhComment[] };
78
121
  return findRespawnComment(parsed.comments ?? [])?.tag ?? null;
79
122
  }
@@ -87,10 +130,13 @@ export async function upsertRespawnComment(
87
130
  },
88
131
  run: RunCommand = runCommand,
89
132
  ): Promise<RespawnPrTag> {
133
+ const repo = `${input.owner}/${input.name}`;
90
134
  const raw = await run("gh", [
91
135
  "pr",
92
136
  "view",
93
137
  String(input.pr),
138
+ "--repo",
139
+ repo,
94
140
  "--json",
95
141
  "comments",
96
142
  ]);
@@ -108,7 +154,15 @@ export async function upsertRespawnComment(
108
154
  `body=${body}`,
109
155
  ]);
110
156
  } else {
111
- await run("gh", ["pr", "comment", String(input.pr), "--body", body]);
157
+ await run("gh", [
158
+ "pr",
159
+ "comment",
160
+ String(input.pr),
161
+ "--repo",
162
+ repo,
163
+ "--body",
164
+ body,
165
+ ]);
112
166
  }
113
167
 
114
168
  return input.tag;
@@ -116,9 +170,12 @@ export async function upsertRespawnComment(
116
170
 
117
171
  export async function checkoutPr(
118
172
  prRef: string,
173
+ repo?: string,
119
174
  run: RunCommand = runCommand,
120
175
  ): Promise<void> {
121
- await run("gh", ["pr", "checkout", prRef]);
176
+ const args = ["pr", "checkout", prNumberFromRef(prRef)];
177
+ if (repo) args.push("--repo", repo);
178
+ await run("gh", args);
122
179
  }
123
180
 
124
181
  function findRespawnComment(