respawn-session 0.0.1
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/LICENSE +21 -0
- package/README.md +133 -0
- package/package.json +30 -0
- package/src/agents/claude.ts +40 -0
- package/src/agents/codex.ts +103 -0
- package/src/agents/index.ts +30 -0
- package/src/agents/types.ts +14 -0
- package/src/cli.ts +69 -0
- package/src/commands/init.ts +53 -0
- package/src/commands/list.ts +21 -0
- package/src/commands/resume.ts +48 -0
- package/src/commands/save.ts +56 -0
- package/src/git.ts +22 -0
- package/src/index-file.ts +82 -0
- package/src/shell.ts +21 -0
- package/src/storage/gist.ts +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Angela Felicia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# respawn-session
|
|
2
|
+
|
|
3
|
+
Save your Claude Code or Codex session to a git branch and resume it later from another worktree or machine.
|
|
4
|
+
|
|
5
|
+
The session is the transcript. `respawn` uploads that transcript to a private GitHub gist, records it in `~/.respawn/index.json`, restores it later, checks out the branch, and starts the same agent with its resume command.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g respawn-session
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package ships a Bun TypeScript CLI with no build step. Bun 1.0 or newer and the GitHub CLI are required:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun --version
|
|
17
|
+
gh auth status
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Initialize local storage and install a Claude Code Stop hook:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
respawn init
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Save the current active agent session from inside a git worktree:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
respawn save
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Resume the latest saved session for a branch:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
respawn angela/fix-bugs
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
List saved sessions:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
respawn list
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Publishing
|
|
47
|
+
|
|
48
|
+
Before publishing, confirm the local runtime and GitHub CLI auth are ready:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
bun --version
|
|
52
|
+
gh auth status
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Run npm's packaging checks first:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npm pack --dry-run
|
|
59
|
+
npm publish --dry-run
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Publish the package:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npm publish
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If npm returns a 403 saying two-factor authentication is required, publish with a current OTP code:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
npm publish --otp <your-current-2fa-code>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
After publish, users can install the CLI globally:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npm install -g respawn-session
|
|
78
|
+
respawn list
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
One-off `npx` usage can download the package, but the bin still uses `#!/usr/bin/env bun`, so Bun must be installed:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
npx respawn-session list
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## How It Works
|
|
88
|
+
|
|
89
|
+
`respawn save` detects the active agent in this order:
|
|
90
|
+
|
|
91
|
+
1. Claude Code via `CLAUDE_SESSION_ID`
|
|
92
|
+
2. Codex via `CODEX_TUI_SESSION_LOG_PATH`, `CODEX_SESSION_ID`, or the newest `~/.codex/sessions/**.jsonl` transcript for the current cwd
|
|
93
|
+
|
|
94
|
+
It then runs:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
gh gist create <transcript>.jsonl --desc "respawn: <repo>@<branch>"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
GitHub CLI gists are secret by default unless `--public` is passed. `respawn` does not pass `--public`.
|
|
101
|
+
|
|
102
|
+
The local index lives at:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
~/.respawn/index.json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
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
|
+
|
|
110
|
+
## Agent Paths
|
|
111
|
+
|
|
112
|
+
Claude Code transcripts are restored to:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Codex transcripts are restored under their saved relative path in:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
~/.codex/sessions/
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Resume commands:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
claude --resume <session-id>
|
|
128
|
+
codex resume <session-id>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## v0 Limits
|
|
132
|
+
|
|
133
|
+
There is no hosted service, no telemetry, and no secret redaction. Transcripts can contain proprietary code or credentials, so use storage you control and treat gists as sensitive even when secret.
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "respawn-session",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Save and resume Claude Code or Codex agent sessions by git branch.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"respawn": "./src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/angelafeliciaa/respawn-session.git"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest",
|
|
28
|
+
"typescript": "latest"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { LocatedTranscript, LocateOptions } from "./types";
|
|
5
|
+
|
|
6
|
+
export function encodeClaudeProjectPath(cwd: string): string {
|
|
7
|
+
return cwd.replaceAll("/", "-");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function transcriptPath(
|
|
11
|
+
sessionId: string,
|
|
12
|
+
options: LocateOptions = {},
|
|
13
|
+
): string {
|
|
14
|
+
const cwd = options.cwd ?? process.cwd();
|
|
15
|
+
const home = options.home ?? homedir();
|
|
16
|
+
return join(
|
|
17
|
+
home,
|
|
18
|
+
".claude",
|
|
19
|
+
"projects",
|
|
20
|
+
encodeClaudeProjectPath(cwd),
|
|
21
|
+
`${sessionId}.jsonl`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function locateTranscript(
|
|
26
|
+
options: LocateOptions = {},
|
|
27
|
+
): LocatedTranscript | null {
|
|
28
|
+
const env = options.env ?? process.env;
|
|
29
|
+
const sessionId = env.CLAUDE_SESSION_ID;
|
|
30
|
+
if (!sessionId) return null;
|
|
31
|
+
|
|
32
|
+
const path = transcriptPath(sessionId, options);
|
|
33
|
+
if (!existsSync(path)) return null;
|
|
34
|
+
|
|
35
|
+
return { agent: "claude", path, sessionId };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resumeCmd(sessionId: string): string[] {
|
|
39
|
+
return ["claude", "--resume", sessionId];
|
|
40
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join, relative } from "node:path";
|
|
4
|
+
import type { LocatedTranscript, LocateOptions } from "./types";
|
|
5
|
+
|
|
6
|
+
const uuidPattern =
|
|
7
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
8
|
+
|
|
9
|
+
type CodexMeta = {
|
|
10
|
+
id: string;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function codexSessionsDir(home = homedir()): string {
|
|
15
|
+
return join(home, ".codex", "sessions");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sessionIdFromCodexPath(path: string): string | null {
|
|
19
|
+
return basename(path).match(uuidPattern)?.[0] ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function transcriptPath(relativePath: string, home = homedir()): string {
|
|
23
|
+
return join(codexSessionsDir(home), relativePath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function locateTranscript(
|
|
27
|
+
options: LocateOptions = {},
|
|
28
|
+
): LocatedTranscript | null {
|
|
29
|
+
const env = options.env ?? process.env;
|
|
30
|
+
const cwd = options.cwd ?? process.cwd();
|
|
31
|
+
const home = options.home ?? homedir();
|
|
32
|
+
const sessionsDir = codexSessionsDir(home);
|
|
33
|
+
|
|
34
|
+
const envPath = env.CODEX_TUI_SESSION_LOG_PATH;
|
|
35
|
+
if (envPath && existsSync(envPath)) {
|
|
36
|
+
const located = locatedFromPath(envPath, cwd, sessionsDir, true);
|
|
37
|
+
if (located) return located;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!existsSync(sessionsDir)) return null;
|
|
41
|
+
|
|
42
|
+
const desiredId = env.CODEX_SESSION_ID;
|
|
43
|
+
const candidates = walkJsonl(sessionsDir)
|
|
44
|
+
.map((path) => locatedFromPath(path, cwd, sessionsDir, false))
|
|
45
|
+
.filter((session): session is LocatedTranscript => Boolean(session))
|
|
46
|
+
.filter((session) => !desiredId || session.sessionId === desiredId);
|
|
47
|
+
|
|
48
|
+
return [...candidates]
|
|
49
|
+
.sort((a, b) => statSync(a.path).mtimeMs - statSync(b.path).mtimeMs)
|
|
50
|
+
.at(-1) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resumeCmd(sessionId: string): string[] {
|
|
54
|
+
return ["codex", "resume", sessionId];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function locatedFromPath(
|
|
58
|
+
path: string,
|
|
59
|
+
cwd: string,
|
|
60
|
+
sessionsDir: string,
|
|
61
|
+
requireMatchingCwd: boolean,
|
|
62
|
+
): LocatedTranscript | null {
|
|
63
|
+
const meta = readCodexMeta(path);
|
|
64
|
+
const sessionId = meta?.id ?? sessionIdFromCodexPath(path);
|
|
65
|
+
if (!sessionId) return null;
|
|
66
|
+
if (requireMatchingCwd && meta?.cwd && meta.cwd !== cwd) return null;
|
|
67
|
+
if (!requireMatchingCwd && meta?.cwd !== cwd) return null;
|
|
68
|
+
|
|
69
|
+
const rel = relative(sessionsDir, path);
|
|
70
|
+
return {
|
|
71
|
+
agent: "codex",
|
|
72
|
+
path,
|
|
73
|
+
sessionId,
|
|
74
|
+
relativePath: rel.startsWith("..") ? undefined : rel,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readCodexMeta(path: string): CodexMeta | null {
|
|
79
|
+
try {
|
|
80
|
+
const lines = readFileSync(path, "utf8").split("\n").slice(0, 20);
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (!line.trim()) continue;
|
|
83
|
+
const parsed = JSON.parse(line) as {
|
|
84
|
+
type?: string;
|
|
85
|
+
payload?: { id?: string; cwd?: string };
|
|
86
|
+
};
|
|
87
|
+
if (parsed.type === "session_meta" && parsed.payload?.id) {
|
|
88
|
+
return { id: parsed.payload.id, cwd: parsed.payload.cwd };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function walkJsonl(dir: string): string[] {
|
|
98
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
99
|
+
const path = join(dir, entry.name);
|
|
100
|
+
if (entry.isDirectory()) return walkJsonl(path);
|
|
101
|
+
return entry.isFile() && entry.name.endsWith(".jsonl") ? [path] : [];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { LocatedTranscript, LocateOptions } from "./types";
|
|
2
|
+
import * as claude from "./claude";
|
|
3
|
+
import * as codex from "./codex";
|
|
4
|
+
|
|
5
|
+
export function locateActiveTranscript(
|
|
6
|
+
options: LocateOptions = {},
|
|
7
|
+
): LocatedTranscript | null {
|
|
8
|
+
return (
|
|
9
|
+
claude.locateTranscript(options) ??
|
|
10
|
+
codex.locateTranscript(options)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resumeCmd(agent: LocatedTranscript["agent"], sessionId: string): string[] {
|
|
15
|
+
if (agent === "claude") return claude.resumeCmd(sessionId);
|
|
16
|
+
return codex.resumeCmd(sessionId);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function targetTranscriptPath(
|
|
20
|
+
transcript: Pick<LocatedTranscript, "agent" | "sessionId" | "relativePath">,
|
|
21
|
+
options: LocateOptions = {},
|
|
22
|
+
): string {
|
|
23
|
+
if (transcript.agent === "claude") {
|
|
24
|
+
return claude.transcriptPath(transcript.sessionId, options);
|
|
25
|
+
}
|
|
26
|
+
if (!transcript.relativePath) {
|
|
27
|
+
throw new Error("Codex session is missing its transcript relative path");
|
|
28
|
+
}
|
|
29
|
+
return codex.transcriptPath(transcript.relativePath, options.home);
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AgentName } from "../index-file";
|
|
2
|
+
|
|
3
|
+
export type LocatedTranscript = {
|
|
4
|
+
agent: AgentName;
|
|
5
|
+
path: string;
|
|
6
|
+
sessionId: string;
|
|
7
|
+
relativePath?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type LocateOptions = {
|
|
11
|
+
cwd?: string;
|
|
12
|
+
home?: string;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
};
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { initRespawn } from "./commands/init";
|
|
3
|
+
import { listSessions } from "./commands/list";
|
|
4
|
+
import { resumeSession } from "./commands/resume";
|
|
5
|
+
import { saveSession } from "./commands/save";
|
|
6
|
+
|
|
7
|
+
export type Route =
|
|
8
|
+
| { name: "help" }
|
|
9
|
+
| { name: "save" }
|
|
10
|
+
| { name: "list" }
|
|
11
|
+
| { name: "init" }
|
|
12
|
+
| { name: "resume"; branch: string };
|
|
13
|
+
|
|
14
|
+
export function route(args: string[]): Route {
|
|
15
|
+
const [command] = args;
|
|
16
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
17
|
+
return { name: "help" };
|
|
18
|
+
}
|
|
19
|
+
if (command === "save" || command === "list" || command === "init") {
|
|
20
|
+
return { name: command };
|
|
21
|
+
}
|
|
22
|
+
return { name: "resume", branch: command };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function main(args = Bun.argv.slice(2)): Promise<void> {
|
|
26
|
+
const selected = route(args);
|
|
27
|
+
if (selected.name === "help") {
|
|
28
|
+
console.log(helpText());
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (selected.name === "save") {
|
|
32
|
+
console.log((await saveSession()).message);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (selected.name === "list") {
|
|
36
|
+
console.log((await listSessions()) || "No saved sessions");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (selected.name === "init") {
|
|
40
|
+
console.log(await initRespawn());
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await resumeSession(selected.branch);
|
|
45
|
+
const [cmd, ...cmdArgs] = result.command;
|
|
46
|
+
const proc = Bun.spawn([cmd, ...cmdArgs], {
|
|
47
|
+
stdin: "inherit",
|
|
48
|
+
stdout: "inherit",
|
|
49
|
+
stderr: "inherit",
|
|
50
|
+
});
|
|
51
|
+
process.exit(await proc.exited);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function helpText(): string {
|
|
55
|
+
return [
|
|
56
|
+
"Usage:",
|
|
57
|
+
" respawn save",
|
|
58
|
+
" respawn <branch>",
|
|
59
|
+
" respawn list",
|
|
60
|
+
" respawn init",
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (import.meta.main) {
|
|
65
|
+
main().catch((error) => {
|
|
66
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { defaultIndexPath, readIndex, writeIndex } from "../index-file";
|
|
5
|
+
|
|
6
|
+
type ClaudeSettings = {
|
|
7
|
+
hooks?: Record<string, Array<{ matcher?: string; hooks?: HookCommand[] }>>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type HookCommand = {
|
|
11
|
+
type: "command";
|
|
12
|
+
command: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type InitDeps = {
|
|
16
|
+
home?: string;
|
|
17
|
+
indexPath?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function initRespawn(deps: InitDeps = {}): Promise<string> {
|
|
21
|
+
const home = deps.home ?? homedir();
|
|
22
|
+
const indexPath = deps.indexPath ?? defaultIndexPath(home);
|
|
23
|
+
await writeIndex(indexPath, await readIndex(indexPath));
|
|
24
|
+
|
|
25
|
+
const settingsPath = join(home, ".claude", "settings.json");
|
|
26
|
+
const settings = await readSettings(settingsPath);
|
|
27
|
+
const stopHooks = settings.hooks?.Stop ?? [];
|
|
28
|
+
const command = "respawn save || true";
|
|
29
|
+
const alreadyInstalled = stopHooks.some((group) =>
|
|
30
|
+
group.hooks?.some((hook) => hook.command === command),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (!alreadyInstalled) {
|
|
34
|
+
stopHooks.push({
|
|
35
|
+
matcher: "",
|
|
36
|
+
hooks: [{ type: "command", command }],
|
|
37
|
+
});
|
|
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
|
+
}
|
|
42
|
+
|
|
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
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defaultIndexPath, readIndex } from "../index-file";
|
|
2
|
+
|
|
3
|
+
export type ListDeps = {
|
|
4
|
+
indexPath?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export async function listSessions(deps: ListDeps = {}): Promise<string> {
|
|
8
|
+
const index = await readIndex(deps.indexPath ?? defaultIndexPath());
|
|
9
|
+
return index.sessions
|
|
10
|
+
.map((session) =>
|
|
11
|
+
[
|
|
12
|
+
session.savedAt,
|
|
13
|
+
session.agent,
|
|
14
|
+
`${session.repo}@${session.branch}`,
|
|
15
|
+
session.sessionId,
|
|
16
|
+
session.sha,
|
|
17
|
+
session.gistUrl,
|
|
18
|
+
].join(" "),
|
|
19
|
+
)
|
|
20
|
+
.join("\n");
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { resumeCmd, targetTranscriptPath } from "../agents";
|
|
4
|
+
import { checkoutBranch, currentRepo } from "../git";
|
|
5
|
+
import {
|
|
6
|
+
defaultIndexPath,
|
|
7
|
+
findLatestSession,
|
|
8
|
+
type SavedSession,
|
|
9
|
+
} from "../index-file";
|
|
10
|
+
import { downloadGist } from "../storage/gist";
|
|
11
|
+
|
|
12
|
+
export type ResumeDeps = {
|
|
13
|
+
indexPath?: string;
|
|
14
|
+
currentRepo?: typeof currentRepo;
|
|
15
|
+
downloadGist?: typeof downloadGist;
|
|
16
|
+
checkoutBranch?: typeof checkoutBranch;
|
|
17
|
+
targetTranscriptPath?: typeof targetTranscriptPath;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function resumeSession(
|
|
21
|
+
branch: string,
|
|
22
|
+
deps: ResumeDeps = {},
|
|
23
|
+
): Promise<{
|
|
24
|
+
command: string[];
|
|
25
|
+
path: string;
|
|
26
|
+
session: SavedSession;
|
|
27
|
+
}> {
|
|
28
|
+
const repo = await (deps.currentRepo ?? currentRepo)();
|
|
29
|
+
const session = await findLatestSession(deps.indexPath ?? defaultIndexPath(), {
|
|
30
|
+
repo,
|
|
31
|
+
branch,
|
|
32
|
+
});
|
|
33
|
+
if (!session) {
|
|
34
|
+
throw new Error(`No saved respawn session found for ${repo}@${branch}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const transcript = await (deps.downloadGist ?? downloadGist)(session.gistUrl);
|
|
38
|
+
const path = (deps.targetTranscriptPath ?? targetTranscriptPath)(session);
|
|
39
|
+
await mkdir(dirname(path), { recursive: true });
|
|
40
|
+
await writeFile(path, transcript);
|
|
41
|
+
await (deps.checkoutBranch ?? checkoutBranch)(branch);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
command: resumeCmd(session.agent, session.sessionId),
|
|
45
|
+
path,
|
|
46
|
+
session,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { locateActiveTranscript } from "../agents";
|
|
2
|
+
import { currentBranch, currentRepo, currentSha } from "../git";
|
|
3
|
+
import {
|
|
4
|
+
defaultIndexPath,
|
|
5
|
+
recordSession,
|
|
6
|
+
type SavedSession,
|
|
7
|
+
} from "../index-file";
|
|
8
|
+
import { createGist } from "../storage/gist";
|
|
9
|
+
|
|
10
|
+
export type SaveDeps = {
|
|
11
|
+
indexPath?: string;
|
|
12
|
+
locateActiveTranscript?: typeof locateActiveTranscript;
|
|
13
|
+
currentRepo?: typeof currentRepo;
|
|
14
|
+
currentBranch?: typeof currentBranch;
|
|
15
|
+
currentSha?: typeof currentSha;
|
|
16
|
+
createGist?: typeof createGist;
|
|
17
|
+
now?: () => Date;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function saveSession(deps: SaveDeps = {}): Promise<{
|
|
21
|
+
message: string;
|
|
22
|
+
session: SavedSession;
|
|
23
|
+
}> {
|
|
24
|
+
const locate = deps.locateActiveTranscript ?? locateActiveTranscript;
|
|
25
|
+
const transcript = locate();
|
|
26
|
+
if (!transcript) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"No active Claude Code or Codex session transcript found. Run respawn save inside an active agent session.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const repo = await (deps.currentRepo ?? currentRepo)();
|
|
33
|
+
const branch = await (deps.currentBranch ?? currentBranch)();
|
|
34
|
+
const sha = await (deps.currentSha ?? currentSha)();
|
|
35
|
+
const gistUrl = await (deps.createGist ?? createGist)(
|
|
36
|
+
transcript.path,
|
|
37
|
+
`respawn: ${repo}@${branch}`,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const session: SavedSession = {
|
|
41
|
+
repo,
|
|
42
|
+
branch,
|
|
43
|
+
gistUrl,
|
|
44
|
+
sessionId: transcript.sessionId,
|
|
45
|
+
sha,
|
|
46
|
+
agent: transcript.agent,
|
|
47
|
+
savedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
48
|
+
relativePath: transcript.relativePath,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
await recordSession(deps.indexPath ?? defaultIndexPath(), session);
|
|
52
|
+
return {
|
|
53
|
+
message: `Saved ${session.agent} session ${session.sessionId} for ${branch}`,
|
|
54
|
+
session,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { runCommand, type RunCommand } from "./shell";
|
|
2
|
+
|
|
3
|
+
export async function currentRepo(run: RunCommand = runCommand): Promise<string> {
|
|
4
|
+
return (await run("git", ["remote", "get-url", "origin"])).trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function currentBranch(
|
|
8
|
+
run: RunCommand = runCommand,
|
|
9
|
+
): Promise<string> {
|
|
10
|
+
return (await run("git", ["branch", "--show-current"])).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function currentSha(run: RunCommand = runCommand): Promise<string> {
|
|
14
|
+
return (await run("git", ["rev-parse", "HEAD"])).trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function checkoutBranch(
|
|
18
|
+
branch: string,
|
|
19
|
+
run: RunCommand = runCommand,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
await run("git", ["checkout", branch]);
|
|
22
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type AgentName = "claude" | "codex";
|
|
6
|
+
|
|
7
|
+
export type SavedSession = {
|
|
8
|
+
repo: string;
|
|
9
|
+
branch: string;
|
|
10
|
+
gistUrl: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
sha: string;
|
|
13
|
+
agent: AgentName;
|
|
14
|
+
savedAt: string;
|
|
15
|
+
relativePath?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type RespawnIndex = {
|
|
19
|
+
version: 1;
|
|
20
|
+
sessions: SavedSession[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SessionQuery = {
|
|
24
|
+
repo: string;
|
|
25
|
+
branch: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function defaultIndexPath(home = homedir()): string {
|
|
29
|
+
return join(home, ".respawn", "index.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readIndex(path = defaultIndexPath()): Promise<RespawnIndex> {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFile(path, "utf8");
|
|
35
|
+
const parsed = JSON.parse(raw) as RespawnIndex;
|
|
36
|
+
return {
|
|
37
|
+
version: 1,
|
|
38
|
+
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
42
|
+
return { version: 1, sessions: [] };
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function writeIndex(
|
|
49
|
+
path: string,
|
|
50
|
+
index: RespawnIndex,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
await mkdir(dirname(path), { recursive: true });
|
|
53
|
+
await writeFile(path, `${JSON.stringify(index, null, 2)}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function recordSession(
|
|
57
|
+
path: string,
|
|
58
|
+
session: SavedSession,
|
|
59
|
+
): Promise<SavedSession> {
|
|
60
|
+
const index = await readIndex(path);
|
|
61
|
+
index.sessions.push(session);
|
|
62
|
+
await writeIndex(path, index);
|
|
63
|
+
return session;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function findSessions(
|
|
67
|
+
path: string,
|
|
68
|
+
query: SessionQuery,
|
|
69
|
+
): Promise<SavedSession[]> {
|
|
70
|
+
const index = await readIndex(path);
|
|
71
|
+
return index.sessions.filter(
|
|
72
|
+
(session) => session.repo === query.repo && session.branch === query.branch,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function findLatestSession(
|
|
77
|
+
path: string,
|
|
78
|
+
query: SessionQuery,
|
|
79
|
+
): Promise<SavedSession | null> {
|
|
80
|
+
const sessions = await findSessions(path, query);
|
|
81
|
+
return [...sessions].sort((a, b) => a.savedAt.localeCompare(b.savedAt)).at(-1) ?? null;
|
|
82
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type RunCommand = (cmd: string, args: string[]) => Promise<string>;
|
|
2
|
+
|
|
3
|
+
export const runCommand: RunCommand = async (cmd, args) => {
|
|
4
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
5
|
+
stdout: "pipe",
|
|
6
|
+
stderr: "pipe",
|
|
7
|
+
});
|
|
8
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
9
|
+
new Response(proc.stdout).text(),
|
|
10
|
+
new Response(proc.stderr).text(),
|
|
11
|
+
proc.exited,
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
if (exitCode !== 0) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`${cmd} ${args.join(" ")} failed with exit ${exitCode}: ${stderr.trim()}`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return stdout;
|
|
21
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { runCommand, type RunCommand } from "../shell";
|
|
2
|
+
|
|
3
|
+
export function gistIdFromUrl(value: string): string {
|
|
4
|
+
const trimmed = value.trim();
|
|
5
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
6
|
+
return segments.at(-1) ?? trimmed;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function createGist(
|
|
10
|
+
transcriptPath: string,
|
|
11
|
+
description: string,
|
|
12
|
+
run: RunCommand = runCommand,
|
|
13
|
+
): Promise<string> {
|
|
14
|
+
return (
|
|
15
|
+
await run("gh", ["gist", "create", transcriptPath, "--desc", description])
|
|
16
|
+
).trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function downloadGist(
|
|
20
|
+
gistUrl: string,
|
|
21
|
+
run: RunCommand = runCommand,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
return run("gh", ["gist", "view", gistIdFromUrl(gistUrl), "--raw"]);
|
|
24
|
+
}
|