@vinh0809it/claude-sessions 0.3.1 → 0.4.0
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 +25 -10
- package/bin/claude-sessions.js +4 -0
- package/lib/ansi.js +66 -0
- package/lib/cli.js +63 -0
- package/lib/config.js +93 -0
- package/lib/format.js +45 -0
- package/lib/launch.js +164 -0
- package/lib/operations.js +137 -0
- package/lib/paths.js +86 -0
- package/lib/sessions.js +236 -0
- package/lib/tui/app.js +525 -0
- package/lib/tui/keys.js +45 -0
- package/lib/tui/line-input.js +63 -0
- package/lib/tui/picker.js +166 -0
- package/package.json +5 -4
- package/index.js +0 -1411
package/README.md
CHANGED
|
@@ -25,10 +25,6 @@ npm install -g @vinh0809it/claude-sessions
|
|
|
25
25
|
|
|
26
26
|
# Or run without installing
|
|
27
27
|
npx @vinh0809it/claude-sessions
|
|
28
|
-
|
|
29
|
-
# Or manually
|
|
30
|
-
curl -fsSL https://raw.githubusercontent.com/vinh0809it/claude-sessions/main/index.js \
|
|
31
|
-
-o ~/.local/bin/claude-sessions && chmod +x ~/.local/bin/claude-sessions
|
|
32
28
|
```
|
|
33
29
|
|
|
34
30
|
Requires Node.js 18+.
|
|
@@ -89,7 +85,7 @@ Sessions are grouped by profile in the UI. Press `m` to move a session to a diff
|
|
|
89
85
|
|----------|---------|-------|
|
|
90
86
|
| **Kitty** | ✅ new tab | Requires `allow_remote_control yes` in `kitty.conf` |
|
|
91
87
|
| **WezTerm** | ✅ new tab | Uses `wezterm cli spawn` |
|
|
92
|
-
| **tmux** | ✅ new window | In the current session |
|
|
88
|
+
| **tmux** | ✅ new window | In the current session, also when tmux runs inside kitty or WezTerm |
|
|
93
89
|
| **iTerm2** | ✅ new tab | Via `osascript` |
|
|
94
90
|
| **Terminal.app** | ✅ new window | Via `osascript` |
|
|
95
91
|
| **Windows Terminal** | ✅ new tab | Via `wt new-tab` (native Windows; in WSL it resumes in place) |
|
|
@@ -130,15 +126,34 @@ Deleted sessions are moved to `<config dir>/.claude-sessions-trash/`; run `claud
|
|
|
130
126
|
|
|
131
127
|
## Contributing
|
|
132
128
|
|
|
133
|
-
Issues and PRs welcome.
|
|
129
|
+
Issues and PRs welcome. Plain Node.js (CommonJS), no dependencies and no build step.
|
|
134
130
|
|
|
135
131
|
```sh
|
|
136
132
|
git clone https://github.com/vinh0809it/claude-sessions
|
|
137
133
|
cd claude-sessions
|
|
138
|
-
node
|
|
139
|
-
npm link
|
|
140
|
-
npm test
|
|
141
|
-
scripts/demo/record.sh
|
|
134
|
+
node bin/claude-sessions.js # run directly
|
|
135
|
+
npm link # install globally from source
|
|
136
|
+
npm test # run the test suite (Node built-in test runner)
|
|
137
|
+
scripts/demo/record.sh # re-record docs/demo.gif from fake sessions (needs Pillow + ffmpeg)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
bin/claude-sessions.js entry point
|
|
142
|
+
lib/
|
|
143
|
+
cli.js argument handling, --list / --json output
|
|
144
|
+
config.js usage text, flags, config dir discovery
|
|
145
|
+
sessions.js reading session files, metadata cache, scanning
|
|
146
|
+
operations.js move, rename, delete to trash, empty trash
|
|
147
|
+
launch.js terminal detection, opening sessions
|
|
148
|
+
paths.js folder-name encoding, path completion
|
|
149
|
+
format.js dates, sizes, paths for display
|
|
150
|
+
ansi.js escape sequences and colors
|
|
151
|
+
tui/
|
|
152
|
+
app.js the full-screen browser
|
|
153
|
+
picker.js profile → folder → path picker (move, new)
|
|
154
|
+
line-input.js editable text field
|
|
155
|
+
keys.js key codes and input splitting
|
|
156
|
+
test/ one file per module
|
|
142
157
|
```
|
|
143
158
|
|
|
144
159
|
## License
|
package/lib/ansi.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Terminal escape sequences and text styling.
|
|
4
|
+
//
|
|
5
|
+
// Each style helper resets only its own attribute, so a row background survives
|
|
6
|
+
// nested colors. NO_COLOR (https://no-color.org) drops colors but keeps
|
|
7
|
+
// bold, dim and inverse.
|
|
8
|
+
|
|
9
|
+
const ESC = '\x1b';
|
|
10
|
+
const COLOR = !process.env.NO_COLOR;
|
|
11
|
+
|
|
12
|
+
const screen = {
|
|
13
|
+
clearLine: `${ESC}[2K`,
|
|
14
|
+
clearToEnd: `${ESC}[K`,
|
|
15
|
+
clear: `${ESC}[2J`,
|
|
16
|
+
hideCursor: `${ESC}[?25l`,
|
|
17
|
+
showCursor: `${ESC}[?25h`,
|
|
18
|
+
altBuffer: `${ESC}[?1049h`,
|
|
19
|
+
mainBuffer: `${ESC}[?1049l`,
|
|
20
|
+
reset: `${ESC}[0m`,
|
|
21
|
+
moveTo: (row, col) => `${ESC}[${row};${col}H`,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const bold = s => `${ESC}[1m${s}${ESC}[22m`;
|
|
25
|
+
const dim = s => `${ESC}[2m${s}${ESC}[22m`;
|
|
26
|
+
const inv = s => `${ESC}[7m${s}${ESC}[27m`;
|
|
27
|
+
const fg = (code, s) => COLOR ? `${ESC}[${code}m${s}${ESC}[22;39m` : s;
|
|
28
|
+
const bg = (code, s) => COLOR ? `${ESC}[${code}m${s}${ESC}[49m` : s;
|
|
29
|
+
|
|
30
|
+
const trunc = (s, n) => n > 0 && s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
31
|
+
const pad = (s, n) => s.padEnd(n).slice(0, n);
|
|
32
|
+
|
|
33
|
+
const SELECTED_BG = `${ESC}[48;5;236m`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A selected line: dark background with an accent bar, or inverse video when
|
|
37
|
+
* colors are off. `styled` must color every visible part explicitly so the line
|
|
38
|
+
* stays readable on light themes; `plain` is the same text without styling.
|
|
39
|
+
*/
|
|
40
|
+
function selectedLine(styled, plain, width) {
|
|
41
|
+
return COLOR
|
|
42
|
+
? `${SELECTED_BG}${fg('96', '▌')}${styled}${screen.clearToEnd}${screen.reset}`
|
|
43
|
+
: inv(pad(` ${plain}`, width));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** "Enter=open /=search" → each key in bold, its label dimmed. */
|
|
47
|
+
function styleHints(s) {
|
|
48
|
+
return s.replace(/(\S+)=(.+?)(?= |$)/g, (_, key, label) => `${bold(key)} ${dim(label)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* "keycloak/fix-login" → the group prefix gets its own color so related
|
|
53
|
+
* sessions stand out. `style` lets plain-output callers pass their own helper.
|
|
54
|
+
*/
|
|
55
|
+
function groupTitle(title, color, style = fg) {
|
|
56
|
+
const i = title.indexOf('/');
|
|
57
|
+
return i > 0
|
|
58
|
+
? style('95', title.slice(0, i + 1)) + style(color, title.slice(i + 1))
|
|
59
|
+
: style(color, title);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
COLOR, screen,
|
|
64
|
+
bold, dim, inv, fg, bg, trunc, pad,
|
|
65
|
+
selectedLine, styleHints, groupTitle,
|
|
66
|
+
};
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { version } = require('../package.json');
|
|
5
|
+
const { groupTitle } = require('./ansi');
|
|
6
|
+
const { USAGE, parseArgs, discoverConfigDirs } = require('./config');
|
|
7
|
+
const { fmtDate, fmtSize, shortenPath } = require('./format');
|
|
8
|
+
const { cache, loadSessions } = require('./sessions');
|
|
9
|
+
const { emptyTrash } = require('./operations');
|
|
10
|
+
const { App } = require('./tui/app');
|
|
11
|
+
|
|
12
|
+
/** Color helper for plain output: styled only when writing to a terminal. */
|
|
13
|
+
function color(code, s) {
|
|
14
|
+
return process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Sessions grouped by profile and project, for --list or piped output. */
|
|
18
|
+
function printList(sessions) {
|
|
19
|
+
if (!sessions.length) { console.log('No sessions found.'); return; }
|
|
20
|
+
let lastConfig = null, lastProject = null;
|
|
21
|
+
for (const s of sessions) {
|
|
22
|
+
if (s.configDir !== lastConfig) {
|
|
23
|
+
if (lastConfig) console.log();
|
|
24
|
+
console.log(color('1;36', `[${s.configDir}]`));
|
|
25
|
+
lastConfig = s.configDir;
|
|
26
|
+
lastProject = null;
|
|
27
|
+
}
|
|
28
|
+
if (s.project !== lastProject) {
|
|
29
|
+
console.log(color('33', ` ${shortenPath(s.project)}`));
|
|
30
|
+
lastProject = s.project;
|
|
31
|
+
}
|
|
32
|
+
const label = s.title
|
|
33
|
+
? groupTitle(s.title, '1;32', color)
|
|
34
|
+
: color('2', `(${(s.firstMessage || 'untitled').slice(0, 60)}…)`);
|
|
35
|
+
console.log(` ${label} ${color('2', `${fmtDate(s.lastModified)} ${fmtSize(s.size)}`)}`);
|
|
36
|
+
console.log(color('2', ` id: ${s.sessionId}`));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function printJson(sessions) {
|
|
41
|
+
const out = sessions.map(({ jsonlPath: _j, configDirPath: _c, ...rest }) => rest);
|
|
42
|
+
console.log(JSON.stringify(out, null, 2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function main(argv = process.argv.slice(2)) {
|
|
46
|
+
const opts = parseArgs(argv);
|
|
47
|
+
if (opts.version) { console.log(version); return; }
|
|
48
|
+
if (opts.help) { console.log(USAGE); return; }
|
|
49
|
+
if (opts.noCache) cache.enabled = false;
|
|
50
|
+
|
|
51
|
+
if (opts.emptyTrash) {
|
|
52
|
+
const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
|
|
53
|
+
console.log(`Freed ${fmtSize(emptyTrash(dirs))}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sessions = loadSessions(opts);
|
|
58
|
+
if (opts.json) printJson(sessions);
|
|
59
|
+
else if (opts.list || !process.stdout.isTTY || !process.stdin.isTTY) printList(sessions);
|
|
60
|
+
else new App(sessions, opts).start();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { main };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { expandHome } = require('./paths');
|
|
7
|
+
|
|
8
|
+
const USAGE = `\
|
|
9
|
+
claude-sessions — Terminal UI for Claude Code sessions
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
claude-sessions [folder] TUI, optionally filtered by folder path
|
|
13
|
+
claude-sessions --list [folder] Plain list output
|
|
14
|
+
claude-sessions --named-only Only /rename'd sessions
|
|
15
|
+
claude-sessions --json JSON output
|
|
16
|
+
claude-sessions -d <path> Scan a specific config dir
|
|
17
|
+
claude-sessions --empty-trash Permanently remove deleted sessions
|
|
18
|
+
claude-sessions --no-cache Ignore the metadata cache
|
|
19
|
+
claude-sessions --version Print version
|
|
20
|
+
claude-sessions -h Help
|
|
21
|
+
|
|
22
|
+
TUI keys:
|
|
23
|
+
↑ ↓ j k Navigate
|
|
24
|
+
PgUp PgDn Page up / down
|
|
25
|
+
g G Jump to top / bottom
|
|
26
|
+
/ Search (↑↓ select, Enter open, Tab keep filter)
|
|
27
|
+
Esc Clear search / marks
|
|
28
|
+
Enter Open session (new tab if supported, else here)
|
|
29
|
+
o Open session in this terminal
|
|
30
|
+
p Toggle preview of recent messages
|
|
31
|
+
r Rename session
|
|
32
|
+
s Cycle sort: date / size / name
|
|
33
|
+
Space Mark session (for bulk delete)
|
|
34
|
+
n New session
|
|
35
|
+
m Move session to another config / folder
|
|
36
|
+
d Delete session(s) to trash
|
|
37
|
+
? Help
|
|
38
|
+
q Ctrl-C Quit
|
|
39
|
+
|
|
40
|
+
Environment variables:
|
|
41
|
+
CLAUDE_SESSIONS_TERMINAL Force terminal: kitty | wezterm | tmux | iterm2 | apple-terminal | windows-terminal | here
|
|
42
|
+
CLAUDE_SESSIONS_OPEN_CMD Custom open command (placeholders: %cmd% %title% %project%)
|
|
43
|
+
CLAUDE_CONFIG_DIR Also scanned when set
|
|
44
|
+
NO_COLOR Disable colors`;
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
47
|
+
const opts = {
|
|
48
|
+
dir: null, namedOnly: false, json: false, list: false, help: false, version: false,
|
|
49
|
+
filterPath: null, emptyTrash: false, noCache: false,
|
|
50
|
+
};
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === '-d' || a === '--dir') opts.dir = argv[++i];
|
|
54
|
+
else if (a === '--named-only') opts.namedOnly = true;
|
|
55
|
+
else if (a === '--json') opts.json = true;
|
|
56
|
+
else if (a === '--list') opts.list = true;
|
|
57
|
+
else if (a === '--empty-trash') opts.emptyTrash = true;
|
|
58
|
+
else if (a === '--no-cache') opts.noCache = true;
|
|
59
|
+
else if (a === '--version' || a === '-v') opts.version = true;
|
|
60
|
+
else if (a === '-h' || a === '--help') opts.help = true;
|
|
61
|
+
else if (!a.startsWith('-')) opts.filterPath = path.resolve(expandHome(a));
|
|
62
|
+
}
|
|
63
|
+
return opts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasProjects(dir) {
|
|
67
|
+
try { return fs.statSync(path.join(dir, 'projects')).isDirectory(); } catch { return false; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every Claude Code config dir: ~/.claude, ~/.claude-*, and $CLAUDE_CONFIG_DIR.
|
|
72
|
+
* Named profiles come first, the default ~/.claude last.
|
|
73
|
+
*/
|
|
74
|
+
function discoverConfigDirs() {
|
|
75
|
+
const home = os.homedir();
|
|
76
|
+
let dirs = [];
|
|
77
|
+
try {
|
|
78
|
+
dirs = fs.readdirSync(home)
|
|
79
|
+
.filter(n => n === '.claude' || n.startsWith('.claude-'))
|
|
80
|
+
.map(n => path.join(home, n));
|
|
81
|
+
} catch {}
|
|
82
|
+
if (process.env.CLAUDE_CONFIG_DIR) dirs.push(path.resolve(process.env.CLAUDE_CONFIG_DIR));
|
|
83
|
+
return [...new Set(dirs)]
|
|
84
|
+
.filter(hasProjects)
|
|
85
|
+
.sort((a, b) => {
|
|
86
|
+
const na = path.basename(a), nb = path.basename(b);
|
|
87
|
+
if (na === '.claude') return 1;
|
|
88
|
+
if (nb === '.claude') return -1;
|
|
89
|
+
return na.localeCompare(nb);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { USAGE, parseArgs, discoverConfigDirs };
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/** "2026-11-10 15:45" in local time. */
|
|
7
|
+
function fmtDate(date) {
|
|
8
|
+
const t = new Date(date);
|
|
9
|
+
const p = n => String(n).padStart(2, '0');
|
|
10
|
+
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())} ${p(t.getHours())}:${p(t.getMinutes())}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** "just now", "5m ago", "2h ago", "3d ago", "2mo ago", "1y ago". */
|
|
14
|
+
function fmtAgo(date, now = Date.now()) {
|
|
15
|
+
const minutes = Math.floor((now - new Date(date)) / 60000);
|
|
16
|
+
if (minutes < 1) return 'just now';
|
|
17
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
18
|
+
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`;
|
|
19
|
+
if (minutes < 43200) return `${Math.floor(minutes / 1440)}d ago`;
|
|
20
|
+
if (minutes < 525600) return `${Math.floor(minutes / 43200)}mo ago`;
|
|
21
|
+
return `${Math.floor(minutes / 525600)}y ago`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Bytes as "512B", "1.5K", "13M", "1.2G". */
|
|
25
|
+
function fmtSize(bytes) {
|
|
26
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
27
|
+
const units = ['K', 'M', 'G'];
|
|
28
|
+
let n = bytes, i = -1;
|
|
29
|
+
do { n /= 1024; i++; } while (n >= 1024 && i < units.length - 1);
|
|
30
|
+
return `${n < 10 ? n.toFixed(1) : Math.round(n)}${units[i]}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** "/home/me/x" → "~/x". */
|
|
34
|
+
function shortenPath(p) {
|
|
35
|
+
const home = os.homedir();
|
|
36
|
+
const underHome = p === home || p.startsWith(home + path.sep) || p.startsWith(home + '/');
|
|
37
|
+
return underHome ? '~' + p.slice(home.length) : p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** ".claude-work" → "work", ".claude" → "default". */
|
|
41
|
+
function shortConfig(configDir) {
|
|
42
|
+
return path.basename(configDir).replace(/^\.claude-?/, '') || 'default';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { fmtDate, fmtAgo, fmtSize, shortenPath, shortConfig };
|
package/lib/launch.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Starting `claude` for a session: in a new terminal tab when the terminal
|
|
4
|
+
// supports it, otherwise in the current one.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
const { readSession } = require('./sessions');
|
|
11
|
+
|
|
12
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
13
|
+
|
|
14
|
+
// Tab commands return at once; this keeps a stuck one (e.g. `kitty @` waiting
|
|
15
|
+
// for a reply that never comes) from freezing the UI.
|
|
16
|
+
const SPAWN_TIMEOUT_MS = 5000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* kitty | wezterm | tmux | iterm2 | apple-terminal | windows-terminal | here
|
|
20
|
+
* (`here` = run in the current terminal).
|
|
21
|
+
*/
|
|
22
|
+
function detectTerminal(env = process.env, platform = process.platform) {
|
|
23
|
+
if (env.CLAUDE_SESSIONS_TERMINAL) return env.CLAUDE_SESSIONS_TERMINAL;
|
|
24
|
+
// tmux first: it is always the innermost layer, and a tmux started from kitty or
|
|
25
|
+
// WezTerm inherits their variables even though their tab commands can't reach it.
|
|
26
|
+
if (env.TMUX) return 'tmux';
|
|
27
|
+
if (env.KITTY_WINDOW_ID !== undefined) return 'kitty';
|
|
28
|
+
if (env.WEZTERM_PANE !== undefined) return 'wezterm';
|
|
29
|
+
if (env.TERM_PROGRAM === 'iTerm.app') return 'iterm2';
|
|
30
|
+
if (env.TERM_PROGRAM === 'Apple_Terminal') return 'apple-terminal';
|
|
31
|
+
// WSL also sees WT_SESSION, but there `wt` would open a Windows shell, not Linux.
|
|
32
|
+
if (env.WT_SESSION && platform === 'win32') return 'windows-terminal';
|
|
33
|
+
return 'here';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** POSIX shell single-quoting. */
|
|
37
|
+
function shellEsc(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
|
38
|
+
|
|
39
|
+
/** AppleScript string literal. */
|
|
40
|
+
function appleStr(s) { return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; }
|
|
41
|
+
|
|
42
|
+
function trySpawn(cmd, args) {
|
|
43
|
+
const r = spawnSync(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'], timeout: SPAWN_TIMEOUT_MS });
|
|
44
|
+
if (r.error) {
|
|
45
|
+
const reason = r.error.code === 'ENOENT' ? 'not found'
|
|
46
|
+
: r.error.code === 'ETIMEDOUT' ? 'timed out'
|
|
47
|
+
: r.error.message;
|
|
48
|
+
return { ok: false, msg: `${cmd}: ${reason}` };
|
|
49
|
+
}
|
|
50
|
+
return r.status === 0
|
|
51
|
+
? { ok: true }
|
|
52
|
+
: { ok: false, msg: r.stderr?.toString().trim() || `${cmd} exited with ${r.status}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The directory to resume a session in: the folder's .project-path hint, else
|
|
57
|
+
* the cwd of a sibling session, else the recorded project, else $HOME.
|
|
58
|
+
*/
|
|
59
|
+
function resolveProjectPath(session) {
|
|
60
|
+
const sessionDir = path.dirname(session.jsonlPath);
|
|
61
|
+
try {
|
|
62
|
+
const hint = fs.readFileSync(path.join(sessionDir, '.project-path'), 'utf8').trim();
|
|
63
|
+
if (hint && fs.existsSync(hint)) return hint;
|
|
64
|
+
} catch {}
|
|
65
|
+
try {
|
|
66
|
+
const siblings = fs.readdirSync(sessionDir)
|
|
67
|
+
.filter(f => f.endsWith('.jsonl') && f !== path.basename(session.jsonlPath));
|
|
68
|
+
for (const sibling of siblings) {
|
|
69
|
+
const { cwd } = readSession(path.join(sessionDir, sibling));
|
|
70
|
+
if (cwd && fs.existsSync(cwd)) return cwd;
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
return fs.existsSync(session.project) ? session.project : os.homedir();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {object} LaunchPlan
|
|
78
|
+
* @property {string} cwd Directory to start claude in
|
|
79
|
+
* @property {string[]} args Arguments for claude
|
|
80
|
+
* @property {string} configDirPath CLAUDE_CONFIG_DIR for the profile
|
|
81
|
+
* @property {string} title Tab title
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/** @returns {LaunchPlan} */
|
|
85
|
+
function resumePlan(session) {
|
|
86
|
+
return {
|
|
87
|
+
cwd: resolveProjectPath(session),
|
|
88
|
+
args: ['-r', session.sessionId],
|
|
89
|
+
configDirPath: session.configDirPath,
|
|
90
|
+
title: session.title || session.sessionId.slice(0, 8),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @returns {LaunchPlan} */
|
|
95
|
+
function newPlan(configDirPath, projectPath) {
|
|
96
|
+
return { cwd: projectPath, args: [], configDirPath, title: path.basename(projectPath) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A POSIX shell command that runs the plan and then leaves an interactive shell. */
|
|
100
|
+
function planShellCmd(plan) {
|
|
101
|
+
const claude = ['claude', ...plan.args].join(' ');
|
|
102
|
+
return `cd ${shellEsc(plan.cwd)} && CLAUDE_CONFIG_DIR=${shellEsc(plan.configDirPath)} ${claude}; exec "\${SHELL:-bash}"`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `wt new-tab` arguments. wt treats ';' as a command separator, so it is
|
|
107
|
+
* escaped; cmd's `set X=v&& …` takes everything up to && as the value.
|
|
108
|
+
*/
|
|
109
|
+
function wtArgs(plan) {
|
|
110
|
+
const escSemi = v => String(v).replace(/;/g, '\\;');
|
|
111
|
+
const command = `set CLAUDE_CONFIG_DIR=${plan.configDirPath}&& claude ${plan.args.join(' ')}`.trim();
|
|
112
|
+
return ['-w', '0', 'nt', '--title', escSemi(plan.title), '-d', escSemi(plan.cwd), 'cmd', '/k', escSemi(command)];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Opens the plan in a new tab. Returns { ok, msg }, or { here: true } when the
|
|
117
|
+
* terminal has no tab support and the caller should use runHere().
|
|
118
|
+
*/
|
|
119
|
+
function launchInTab(plan) {
|
|
120
|
+
const shellCmd = planShellCmd(plan);
|
|
121
|
+
const template = process.env.CLAUDE_SESSIONS_OPEN_CMD;
|
|
122
|
+
if (template) {
|
|
123
|
+
const command = template
|
|
124
|
+
.replace(/%cmd%/g, shellEsc(shellCmd))
|
|
125
|
+
.replace(/%title%/g, shellEsc(plan.title))
|
|
126
|
+
.replace(/%project%/g, shellEsc(plan.cwd));
|
|
127
|
+
const r = spawnSync('bash', ['-c', command], { stdio: 'ignore' });
|
|
128
|
+
return r.status === 0 ? { ok: true } : { ok: false, msg: `Custom open command failed (exit ${r.status})` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
switch (detectTerminal()) {
|
|
132
|
+
case 'kitty':
|
|
133
|
+
return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${plan.title}`, 'bash', '-i', '-c', shellCmd]);
|
|
134
|
+
case 'wezterm':
|
|
135
|
+
return trySpawn('wezterm', ['cli', 'spawn', '--', 'bash', '-i', '-c', shellCmd]);
|
|
136
|
+
case 'tmux':
|
|
137
|
+
return trySpawn('tmux', ['new-window', '-n', plan.title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
|
|
138
|
+
case 'iterm2':
|
|
139
|
+
return trySpawn('osascript', ['-e',
|
|
140
|
+
`tell application "iTerm2" to tell current window to create tab with default profile command ${appleStr('bash -i -c ' + shellEsc(shellCmd))}`]);
|
|
141
|
+
case 'apple-terminal':
|
|
142
|
+
return trySpawn('osascript', ['-e', `tell application "Terminal" to do script ${appleStr(shellCmd)}`]);
|
|
143
|
+
case 'windows-terminal':
|
|
144
|
+
return trySpawn('wt', wtArgs(plan));
|
|
145
|
+
default:
|
|
146
|
+
return { here: true };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Runs claude in the current terminal and waits for it to exit. */
|
|
151
|
+
function runHere(plan) {
|
|
152
|
+
const r = spawnSync('claude', plan.args, {
|
|
153
|
+
cwd: plan.cwd,
|
|
154
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: plan.configDirPath },
|
|
155
|
+
stdio: 'inherit',
|
|
156
|
+
shell: IS_WINDOWS, // claude may be a .cmd shim on Windows
|
|
157
|
+
});
|
|
158
|
+
if (r.error) return { ok: false, msg: r.error.code === 'ENOENT' ? '`claude` not found on PATH' : r.error.message };
|
|
159
|
+
return { ok: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
detectTerminal, resolveProjectPath, resumePlan, newPlan, wtArgs, launchInTab, runHere,
|
|
164
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Changes to session files: move, rename, delete to trash, empty trash.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { readSession } = require('./sessions');
|
|
8
|
+
const { shortenPath } = require('./format');
|
|
9
|
+
|
|
10
|
+
const TRASH_DIR = '.claude-sessions-trash';
|
|
11
|
+
|
|
12
|
+
/** rename(2), falling back to copy + remove across filesystems (EXDEV). */
|
|
13
|
+
function movePath(src, dest) {
|
|
14
|
+
try { fs.renameSync(src, dest); }
|
|
15
|
+
catch (e) {
|
|
16
|
+
if (e.code !== 'EXDEV') throw e;
|
|
17
|
+
fs.cpSync(src, dest, { recursive: true, errorOnExist: true, force: false });
|
|
18
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** <id>.jsonl may have a sibling <id>/ folder (subagent transcripts, tool output). */
|
|
23
|
+
function companionDir(jsonlPath) {
|
|
24
|
+
return jsonlPath.replace(/\.jsonl$/, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function moveWithCompanion(jsonlPath, destJsonl) {
|
|
28
|
+
movePath(jsonlPath, destJsonl);
|
|
29
|
+
const dir = companionDir(jsonlPath);
|
|
30
|
+
try { if (fs.statSync(dir).isDirectory()) movePath(dir, companionDir(destJsonl)); } catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Moves a session into <configDir>/.claude-sessions-trash/<folder>/ (same
|
|
35
|
+
* filesystem, so it is a cheap rename). Returns the trash folder.
|
|
36
|
+
*/
|
|
37
|
+
function deleteSessionFiles(session) {
|
|
38
|
+
const folderName = path.basename(path.dirname(session.jsonlPath));
|
|
39
|
+
const trashDir = path.join(session.configDirPath, TRASH_DIR, folderName);
|
|
40
|
+
fs.mkdirSync(trashDir, { recursive: true });
|
|
41
|
+
let base = path.basename(session.jsonlPath, '.jsonl');
|
|
42
|
+
if (fs.existsSync(path.join(trashDir, base + '.jsonl'))) base += '-' + Date.now();
|
|
43
|
+
moveWithCompanion(session.jsonlPath, path.join(trashDir, base + '.jsonl'));
|
|
44
|
+
return trashDir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function diskUsage(p) {
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.lstatSync(p);
|
|
50
|
+
if (!stat.isDirectory()) return stat.size;
|
|
51
|
+
return fs.readdirSync(p).reduce((sum, n) => sum + diskUsage(path.join(p, n)), 0);
|
|
52
|
+
} catch { return 0; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Permanently removes the trash of each config dir. Returns the bytes freed. */
|
|
56
|
+
function emptyTrash(configDirs) {
|
|
57
|
+
let freed = 0;
|
|
58
|
+
for (const dir of configDirs) {
|
|
59
|
+
const trash = path.join(dir, TRASH_DIR);
|
|
60
|
+
if (!fs.existsSync(trash)) continue;
|
|
61
|
+
freed += diskUsage(trash);
|
|
62
|
+
fs.rmSync(trash, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
return freed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Moves a session to another config dir and/or project folder, updating the
|
|
69
|
+
* session object in place. `targetRawPath` is recorded as the folder's
|
|
70
|
+
* .project-path hint when the folder is new.
|
|
71
|
+
*/
|
|
72
|
+
function moveSession(session, targetConfigDirPath, targetFolderName, targetRawPath = null) {
|
|
73
|
+
const folderName = targetFolderName || path.basename(path.dirname(session.jsonlPath));
|
|
74
|
+
const targetDir = path.join(targetConfigDirPath, 'projects', folderName);
|
|
75
|
+
const targetJsonl = path.join(targetDir, path.basename(session.jsonlPath));
|
|
76
|
+
if (path.resolve(targetJsonl) === path.resolve(session.jsonlPath)) return;
|
|
77
|
+
if (fs.existsSync(targetJsonl)) throw new Error('a session with this id already exists there');
|
|
78
|
+
|
|
79
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
80
|
+
moveWithCompanion(session.jsonlPath, targetJsonl);
|
|
81
|
+
|
|
82
|
+
if (targetRawPath) {
|
|
83
|
+
try { fs.writeFileSync(path.join(targetDir, '.project-path'), targetRawPath, 'utf8'); } catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
session.configDir = path.basename(targetConfigDirPath);
|
|
87
|
+
session.configDirPath = targetConfigDirPath;
|
|
88
|
+
session.jsonlPath = targetJsonl;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Appends a custom-title record, the same thing /rename writes. */
|
|
92
|
+
function renameSession(session, title) {
|
|
93
|
+
title = title.trim();
|
|
94
|
+
if (!title) throw new Error('empty title');
|
|
95
|
+
const fd = fs.openSync(session.jsonlPath, 'r+');
|
|
96
|
+
try {
|
|
97
|
+
const size = fs.fstatSync(fd).size;
|
|
98
|
+
let prefix = '';
|
|
99
|
+
if (size > 0) {
|
|
100
|
+
const last = Buffer.alloc(1);
|
|
101
|
+
fs.readSync(fd, last, 0, 1, size - 1);
|
|
102
|
+
if (last[0] !== 0x0a) prefix = '\n';
|
|
103
|
+
}
|
|
104
|
+
const record = JSON.stringify({ type: 'custom-title', customTitle: title, sessionId: session.sessionId });
|
|
105
|
+
fs.writeSync(fd, prefix + record + '\n', size);
|
|
106
|
+
} finally { fs.closeSync(fd); }
|
|
107
|
+
session.title = title;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Project folders of a config dir with a readable path for each, sorted by that path. */
|
|
111
|
+
function listProjectFolders(configDirPath) {
|
|
112
|
+
const projectsDir = path.join(configDirPath, 'projects');
|
|
113
|
+
const displayPathOf = (folderName) => {
|
|
114
|
+
const folderDir = path.join(projectsDir, folderName);
|
|
115
|
+
try {
|
|
116
|
+
const hint = fs.readFileSync(path.join(folderDir, '.project-path'), 'utf8').trim();
|
|
117
|
+
if (hint) return shortenPath(hint);
|
|
118
|
+
} catch {
|
|
119
|
+
try {
|
|
120
|
+
const file = fs.readdirSync(folderDir).find(f => f.endsWith('.jsonl'));
|
|
121
|
+
const cwd = file && readSession(path.join(folderDir, file)).cwd;
|
|
122
|
+
if (cwd) return shortenPath(cwd);
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
return folderName;
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
return fs.readdirSync(projectsDir)
|
|
129
|
+
.filter(d => { try { return fs.statSync(path.join(projectsDir, d)).isDirectory(); } catch { return false; } })
|
|
130
|
+
.map(d => ({ projectDirName: d, displayPath: displayPathOf(d) }))
|
|
131
|
+
.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
|
|
132
|
+
} catch { return []; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
deleteSessionFiles, emptyTrash, moveSession, renameSession, listProjectFolders,
|
|
137
|
+
};
|