@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 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. The entire tool is a single `index.js` with no build step.
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 index.js # run directly
139
- npm link # install globally from source
140
- npm test # run the test suite (Node built-in test runner, no extra deps)
141
- scripts/demo/record.sh # re-record docs/demo.gif from fake sessions (needs Pillow + ffmpeg)
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
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../lib/cli').main();
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
+ };