ccnav 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Brown
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,78 @@
1
+ # ccnav — Claude Codex Navigator
2
+
3
+ A terminal picker for the Claude Code and Codex sessions on this machine. Pick one and it resumes in the directory it was started in, whichever agent it belongs to.
4
+
5
+ Works on Windows, macOS and Linux with Node 20 or later. Everything that differs by OS (launching the agent, new tabs, Finder/Explorer, clipboard, shell function) is in `src/platform.mjs`; everything that differs by agent is in `src/providers/`.
6
+
7
+ ## Install
8
+
9
+ From a checkout (any OS):
10
+
11
+ ```sh
12
+ npm install
13
+ npm link # puts `ccnav` on PATH
14
+ ccnav --install-shell # optional: leave your shell in the session's directory afterwards
15
+ ```
16
+
17
+ Or build a tarball once and install it anywhere, e.g. on a Mac:
18
+
19
+ ```sh
20
+ npm pack # -> ccnav-1.0.0.tgz
21
+ npm install -g ./ccnav-1.0.0.tgz
22
+ ```
23
+
24
+ `--install-shell` writes a function to the right file for your shell: the PowerShell `$PROFILE` on Windows, `~/.zshrc` for zsh (macOS default), `~/.bash_profile` (macOS) or `~/.bashrc` (Linux) for bash, and `~/.config/fish/functions/ccnav.fish` for fish. Running it again replaces the block rather than adding a second one.
25
+
26
+ New tabs (`t`) use Windows Terminal, iTerm2, WezTerm, kitty, GNOME Terminal or Konsole. Terminal.app gets a new window, because real tabs need Accessibility permission.
27
+
28
+ ## Use
29
+
30
+ ```
31
+ ccnav open the picker
32
+ ccnav --claude only Claude Code sessions
33
+ ccnav --codex only Codex sessions
34
+ ccnav --list print sessions and exit
35
+ ccnav --json print sessions as JSON and exit
36
+ ccnav --model opus any other arguments are passed to the agent
37
+ ```
38
+
39
+ | Key | Action |
40
+ |---|---|
41
+ | ↑ ↓ / j k, PgUp PgDn, g G | move |
42
+ | [ ] | jump to the previous / next directory |
43
+ | Tab | cycle agents: both → Claude → Codex |
44
+ | Enter | resume in its directory |
45
+ | t | resume in a new terminal tab (picker stays open) |
46
+ | f | fork (resume under a new session id) |
47
+ | n | new session in that directory, with the same agent |
48
+ | o | open the directory in Explorer, Finder or the Linux file manager |
49
+ | c | copy the session id |
50
+ | / | filter titles, paths, branches and prompts; Tab switches to full-transcript search |
51
+ | s | cycle sort: last used, created, size, directory name (sessions stay grouped by directory) |
52
+ | x | archive the selected session |
53
+ | X | archive every listed session not used in N days |
54
+ | a | show or hide Claude Desktop scratch sessions |
55
+ | q / Esc | quit |
56
+
57
+ `◆` in cyan marks a Claude Code session, `●` in green a Codex one.
58
+
59
+ ## Where sessions come from
60
+
61
+ | | Claude Code | Codex |
62
+ |---|---|---|
63
+ | Files | `~/.claude/projects/<encoded-dir>/<id>.jsonl` | `~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl` |
64
+ | Override | `$CLAUDE_CONFIG_DIR` | `$CODEX_HOME` |
65
+ | Title | `ai-title` record | `thread_name` in `~/.codex/session_index.jsonl`, else the first prompt |
66
+ | Resume / fork | `claude --resume <id>` / `--fork-session` | `codex resume <id>` / `codex fork <id>` |
67
+ | Hidden | subagent sidechain folders, Desktop scratch workspaces | subagent threads spawned by other sessions |
68
+
69
+ Only the first and last 256 KB of each transcript are read when listing, so a 21 MB session costs the same as a small one. Full-text search reads whole files.
70
+
71
+ Archived sessions move to `~/.claude/ccnav-archive/` or `~/.codex/ccnav-archive/`, keeping the folders they had underneath (`<project>/` for Claude, `YYYY/MM/DD/` for Codex). Moving a file back restores it. Sessions used in the last 10 minutes are never archived, because they may still be open. Archiving a Codex session leaves its line in `session_index.jsonl`; that is harmless but not cleaned up.
72
+
73
+ ## Test
74
+
75
+ ```sh
76
+ npm test # unit + UI tests, any OS
77
+ docker run --rm -v "$PWD:/src:ro" node:24 bash /src/test/docker-e2e.sh # Linux install + zsh end-to-end
78
+ ```
package/bin/ccnav.mjs ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import { App } from '../src/app.mjs';
5
+ import { ago, humanSize, listSessions, providerOf, shortPath } from '../src/sessions.mjs';
6
+ import { installShellFunction, launchAgent, reportCwd } from '../src/actions.mjs';
7
+
8
+ const args = process.argv.slice(2);
9
+
10
+ if (args.includes('-h') || args.includes('--help')) {
11
+ console.log(`ccnav - Claude Codex Navigator: pick a Claude Code or Codex session
12
+ and resume it in its own directory
13
+
14
+ usage:
15
+ ccnav open the session picker
16
+ ccnav --claude only Claude Code sessions
17
+ ccnav --codex only Codex sessions
18
+ ccnav --list print sessions as a table and exit
19
+ ccnav --json print sessions as JSON and exit
20
+ ccnav --install-shell add a shell function (PowerShell, zsh, bash or fish) so
21
+ your shell is left in the session's directory afterwards
22
+
23
+ Any other arguments are passed to the agent (e.g. ccnav --model opus).`);
24
+ process.exit(0);
25
+ }
26
+
27
+ if (args.includes('--install-shell')) {
28
+ const { shell, file, reload } = installShellFunction();
29
+ console.log(`ccnav ${shell} function written to ${file}\nto use it now: ${reload}`);
30
+ process.exit(0);
31
+ }
32
+
33
+ // PowerShell swallows a bare `--`, so anything ccnav doesn't recognise goes to the agent.
34
+ const OWN_FLAGS = new Set(['--list', '--json', '--install-shell', '--claude', '--codex', '-h', '--help', '--']);
35
+ const passthrough = args.filter(a => !OWN_FLAGS.has(a));
36
+ const agents = ['claude', 'codex'].filter(a => args.includes(`--${a}`));
37
+ const sessions = listSessions(agents.length ? { agents } : {});
38
+
39
+ if (args.includes('--json')) {
40
+ console.log(JSON.stringify(sessions, null, 2));
41
+ process.exit(0);
42
+ }
43
+
44
+ if (args.includes('--list')) {
45
+ for (const s of sessions) {
46
+ console.log([s.agent.padEnd(6), ago(s.lastUsed).padEnd(9), ago(s.created).padEnd(9), humanSize(s.size).padStart(6),
47
+ shortPath(s.cwd).padEnd(40), (s.title || s.firstPrompt).replace(/\s+/g, ' ').slice(0, 60)].join(' '));
48
+ }
49
+ process.exit(0);
50
+ }
51
+
52
+ if (!process.stdin.isTTY) {
53
+ console.error('ccnav needs an interactive terminal (use --list or --json otherwise)');
54
+ process.exit(1);
55
+ }
56
+
57
+ let choice = null;
58
+ const app = render(React.createElement(App, { sessions, passthrough, onChoose: c => { choice = c; } }));
59
+ await app.waitUntilExit();
60
+
61
+ if (!choice) process.exit(0);
62
+ const { action, session } = choice;
63
+
64
+ const provider = providerOf(session);
65
+ const agentArgs = provider.launchArgs(action, session, passthrough);
66
+
67
+ reportCwd(session.cwd);
68
+ console.log(`\n→ ${session.cwd}\n→ ${provider.command} ${agentArgs.join(' ')}\n`);
69
+ process.exit(await launchAgent(provider.command, session.cwd, agentArgs));
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "ccnav",
3
+ "version": "1.0.0",
4
+ "description": "Claude Codex Navigator - pick a Claude Code or Codex session and resume it in its directory",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
8
+ "keywords": [
9
+ "claude",
10
+ "claude-code",
11
+ "tui",
12
+ "cli",
13
+ "codex"
14
+ ],
15
+ "author": "rts-mb",
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "bin": {
19
+ "ccnav": "bin/ccnav.mjs"
20
+ },
21
+ "dependencies": {
22
+ "ink": "^7.1.1",
23
+ "react": "^19.3.0"
24
+ },
25
+ "devDependencies": {
26
+ "ink-testing-library": "^4.0.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "src",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/rts-mb/ccnav.git"
40
+ },
41
+ "homepage": "https://github.com/rts-mb/ccnav#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/rts-mb/ccnav/issues"
44
+ }
45
+ }
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export { copyToClipboard, installShellFunction, launchAgent, openFolder, openInNewTab } from './platform.mjs';
4
+
5
+ // Sessions touched this recently may be open in another terminal.
6
+ export const LIVE_WINDOW_MS = 10 * 60 * 1000;
7
+ export const isMaybeLive = s => Date.now() - s.lastUsed < LIVE_WINDOW_MS;
8
+
9
+ // Case-insensitive search through the whole transcript; returns a short snippet or null.
10
+ export function searchTranscript(session, query) {
11
+ const q = query.toLowerCase();
12
+ let text;
13
+ try { text = fs.readFileSync(session.file, 'utf8'); } catch { return null; }
14
+ const lower = text.toLowerCase();
15
+ const idx = lower.indexOf(q);
16
+ if (idx < 0) return null;
17
+ // Prefer the matching string value from the parsed record over raw JSON.
18
+ const lineStart = text.lastIndexOf('\n', idx) + 1;
19
+ const lineEnd = text.indexOf('\n', idx);
20
+ let hay = text.slice(Math.max(0, idx - 60), idx + q.length + 80);
21
+ try {
22
+ const found = findString(JSON.parse(text.slice(lineStart, lineEnd < 0 ? undefined : lineEnd)), q);
23
+ if (found) {
24
+ const i = found.toLowerCase().indexOf(q);
25
+ hay = found.slice(Math.max(0, i - 60), i + q.length + 80);
26
+ }
27
+ } catch { /* fall back to raw slice */ }
28
+ return hay.replace(/\\[nrt]/g, ' ').replace(/\s+/g, ' ').trim();
29
+ }
30
+
31
+ function findString(v, q) {
32
+ if (typeof v === 'string') return v.toLowerCase().includes(q) ? v : null;
33
+ if (v && typeof v === 'object') {
34
+ for (const x of Object.values(v)) {
35
+ const r = findString(x, q);
36
+ if (r) return r;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ // Moves the transcript (and any sidechain folder) into the agent's ccnav-archive, keeping the
43
+ // path it had under the sessions root, so moving it back restores the session.
44
+ export function archiveSession(session) {
45
+ const move = (from) => {
46
+ if (!fs.existsSync(from)) return;
47
+ const to = path.join(session.archiveRoot, path.relative(session.root, from));
48
+ fs.mkdirSync(path.dirname(to), { recursive: true });
49
+ try { fs.renameSync(from, to); } catch {
50
+ fs.cpSync(from, to, { recursive: true });
51
+ fs.rmSync(from, { recursive: true, force: true });
52
+ }
53
+ };
54
+ move(session.file);
55
+ for (const extra of session.extraPaths || []) move(extra);
56
+ }
57
+
58
+ // Written for the shell wrapper function so the calling shell can cd afterwards.
59
+ export function reportCwd(dir) {
60
+ const file = process.env.CCNAV_CWD_FILE;
61
+ if (file) try { fs.writeFileSync(file, dir, 'utf8'); } catch { /* ignore */ }
62
+ }
package/src/app.mjs ADDED
@@ -0,0 +1,313 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
+ import { AGENTS, ago, humanSize, providerOf } from './sessions.mjs';
4
+ import { archiveSession, copyToClipboard, isMaybeLive, openFolder, openInNewTab, searchTranscript } from './actions.mjs';
5
+
6
+ const h = React.createElement;
7
+
8
+ const SORTS = [
9
+ { key: 'lastUsed', label: 'last used', fn: (a, b) => b.lastUsed - a.lastUsed },
10
+ { key: 'created', label: 'created', fn: (a, b) => b.created - a.created },
11
+ { key: 'size', label: 'size', fn: (a, b) => b.size - a.size },
12
+ { key: 'project', label: 'directory name', fn: (a, b) => a.cwd.toLowerCase().localeCompare(b.cwd.toLowerCase()) || b.lastUsed - a.lastUsed },
13
+ ];
14
+
15
+ const pad = (s, n) => {
16
+ s = String(s ?? '');
17
+ return s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s.padEnd(n);
18
+ };
19
+ const oneLine = s => String(s || '').replace(/\s+/g, ' ').trim();
20
+ const keyOf = s => `${s.agent}:${s.id}`;
21
+ const DAY = 86400 * 1000;
22
+
23
+ // mode: list | filter | confirm-archive | prune-days | confirm-prune
24
+ export function App({ sessions: initialSessions, onChoose, passthrough = [] }) {
25
+ const { exit } = useApp();
26
+ const { stdout } = useStdout();
27
+ const width = stdout?.columns || 120;
28
+ const height = stdout?.rows || 30;
29
+
30
+ const [sessions, setSessions] = useState(initialSessions);
31
+ const [cursor, setCursor] = useState(0);
32
+ const [sortIdx, setSortIdx] = useState(0);
33
+ const [filter, setFilter] = useState('');
34
+ const [mode, setMode] = useState('list');
35
+ const [deep, setDeep] = useState(false);
36
+ const [deepHits, setDeepHits] = useState(null); // Map id -> snippet, for the last deep query
37
+ const [searching, setSearching] = useState(false);
38
+ const [showScratch, setShowScratch] = useState(false);
39
+ const [agentIdx, setAgentIdx] = useState(0); // 0 = all agents, then one per agent
40
+ const [message, setMessage] = useState(null); // { text, color }
41
+ const [days, setDays] = useState('30');
42
+
43
+ // Deep search reads whole transcripts, so it runs once per submitted query rather than per keystroke.
44
+ useEffect(() => {
45
+ if (!searching) return;
46
+ const t = setTimeout(() => {
47
+ const hits = new Map();
48
+ for (const s of sessions) {
49
+ const snip = searchTranscript(s, filter);
50
+ if (snip) hits.set(keyOf(s), snip);
51
+ }
52
+ setDeepHits(hits);
53
+ setSearching(false);
54
+ setCursor(0);
55
+ }, 10);
56
+ return () => clearTimeout(t);
57
+ }, [searching]);
58
+
59
+ const sorted = useMemo(() => {
60
+ const q = filter.toLowerCase();
61
+ const onlyAgent = AGENTS[agentIdx - 1];
62
+ return sessions
63
+ .filter(s => showScratch || !s.scratch)
64
+ .filter(s => !onlyAgent || s.agent === onlyAgent)
65
+ .filter(s => {
66
+ if (!q) return true;
67
+ if (deep) return deepHits ? deepHits.has(keyOf(s)) : true;
68
+ return [s.title, s.cwd, s.branch, s.firstPrompt, s.lastText, s.id, s.agent]
69
+ .some(v => v && v.toLowerCase().includes(q));
70
+ })
71
+ .sort(SORTS[sortIdx].fn);
72
+ }, [sessions, filter, sortIdx, showScratch, deep, deepHits, agentIdx]);
73
+
74
+ // Group by directory; groups appear in the order of their best session under the current sort,
75
+ // and `rows` is re-ordered to match so the cursor walks sessions top to bottom.
76
+ const { rows, groups } = useMemo(() => {
77
+ const byDir = new Map();
78
+ for (const s of sorted) {
79
+ const k = s.cwd.toLowerCase();
80
+ if (!byDir.has(k)) byDir.set(k, { cwd: s.cwd, exists: s.cwdExists, sessions: [] });
81
+ byDir.get(k).sessions.push(s);
82
+ }
83
+ const groups = [...byDir.values()];
84
+ return { groups, rows: groups.flatMap(g => g.sessions) };
85
+ }, [sorted]);
86
+
87
+ const pruneTargets = useMemo(() => {
88
+ const n = Number(days);
89
+ if (!Number.isFinite(n) || n < 1) return [];
90
+ return rows.filter(s => Date.now() - s.lastUsed > n * DAY && !isMaybeLive(s));
91
+ }, [rows, days]);
92
+
93
+ const sel = rows[Math.min(cursor, rows.length - 1)];
94
+ const listHeight = Math.max(4, height - 13);
95
+
96
+ // Display lines: a (possibly wrapping) header per directory, then its sessions.
97
+ const items = useMemo(() => {
98
+ const out = [];
99
+ let i = 0;
100
+ for (const g of groups) {
101
+ const label = `▾ ${g.cwd} (${g.sessions.length}${g.exists ? '' : ', missing'})`;
102
+ out.push({ kind: 'dir', group: g, label, lines: Math.max(1, Math.ceil(label.length / Math.max(20, width))) });
103
+ for (const s of g.sessions) out.push({ kind: 'session', session: s, index: i++, lines: 1 });
104
+ }
105
+ return out;
106
+ }, [groups, width]);
107
+
108
+ // Pick a window of items that fits listHeight lines, keeping the cursor roughly centred.
109
+ const visible = useMemo(() => {
110
+ let ci = items.findIndex(it => it.kind === 'session' && it.index === cursor);
111
+ if (ci < 0) ci = 0;
112
+ if (!items.length) return [];
113
+ let start = ci, end = ci + 1, used = items[ci].lines;
114
+ const up = limit => { while (start > 0 && used + items[start - 1].lines <= limit) used += items[--start].lines; };
115
+ const down = () => { while (end < items.length && used + items[end].lines <= listHeight) used += items[end++].lines; };
116
+ up(Math.floor(listHeight / 2));
117
+ down();
118
+ up(listHeight);
119
+ const win = items.slice(start, end);
120
+ // Pin the directory header when the window starts mid-group.
121
+ if (win[0]?.kind === 'session') {
122
+ const hdrIdx = items.slice(0, start).findLastIndex(it => it.kind === 'dir');
123
+ const hdr = { ...items[hdrIdx], label: items[hdrIdx].label + ' (cont.)' };
124
+ let spare = listHeight - used;
125
+ while (spare < hdr.lines && win.length > 1 && win[win.length - 1] !== items[ci]) spare += win.pop().lines;
126
+ while (spare < hdr.lines && win.length > 1) spare += win.shift().lines;
127
+ if (spare >= hdr.lines) win.unshift(hdr);
128
+ }
129
+ return win;
130
+ }, [items, cursor, listHeight]);
131
+
132
+ const flash = (text, color = 'green') => setMessage({ text, color });
133
+
134
+ const requireDir = () => {
135
+ if (sel.cwdExists) return true;
136
+ flash(`Directory no longer exists: ${sel.cwd}`, 'red');
137
+ return false;
138
+ };
139
+
140
+ const choose = action => {
141
+ if (!sel || !requireDir()) return;
142
+ onChoose({ action, session: sel });
143
+ exit();
144
+ };
145
+
146
+ const removeSessions = gone => {
147
+ const ids = new Set(gone.map(keyOf));
148
+ setSessions(list => list.filter(s => !ids.has(keyOf(s))));
149
+ setCursor(c => Math.max(0, Math.min(c, rows.length - 1 - gone.length)));
150
+ };
151
+
152
+ const archive = targets => {
153
+ const done = [];
154
+ for (const s of targets) {
155
+ try { archiveSession(s); done.push(s); } catch (e) { flash(`Archive failed for ${s.id}: ${e.message}`, 'red'); }
156
+ }
157
+ removeSessions(done);
158
+ if (done.length) flash(`Archived ${done.length} session${done.length === 1 ? '' : 's'} to the agent's ccnav-archive folder`);
159
+ };
160
+
161
+ useInput((input, key) => {
162
+ setMessage(null);
163
+
164
+ if (mode === 'filter') {
165
+ if (key.escape) { setMode('list'); setFilter(''); setDeepHits(null); return; }
166
+ if (key.tab) { setDeep(d => !d); setDeepHits(null); return; }
167
+ if (key.return) { setMode('list'); if (deep && filter) setSearching(true); return; }
168
+ if (key.backspace || key.delete) { setFilter(f => f.slice(0, -1)); setDeepHits(null); setCursor(0); return; }
169
+ if (input && !key.ctrl && !key.meta) { setFilter(f => f + input); setDeepHits(null); setCursor(0); }
170
+ return;
171
+ }
172
+
173
+ if (mode === 'confirm-archive') {
174
+ if (input === 'y' || input === 'Y') archive([sel]);
175
+ setMode('list');
176
+ return;
177
+ }
178
+
179
+ if (mode === 'prune-days') {
180
+ if (key.escape) { setMode('list'); return; }
181
+ if (key.return) { setMode(pruneTargets.length ? 'confirm-prune' : 'list'); if (!pruneTargets.length) flash('Nothing that old.', 'yellow'); return; }
182
+ if (key.backspace || key.delete) { setDays(d => d.slice(0, -1)); return; }
183
+ if (/^\d$/.test(input)) setDays(d => (d + input).slice(0, 4));
184
+ return;
185
+ }
186
+
187
+ if (mode === 'confirm-prune') {
188
+ if (input === 'y' || input === 'Y') archive(pruneTargets);
189
+ setMode('list');
190
+ return;
191
+ }
192
+
193
+ const move = d => setCursor(c => Math.max(0, Math.min(rows.length - 1, c + d)));
194
+ if (key.upArrow || input === 'k') move(-1);
195
+ else if (key.downArrow || input === 'j') move(1);
196
+ else if (key.pageUp) move(-listHeight);
197
+ else if (key.pageDown) move(listHeight);
198
+ else if (input === 'g') setCursor(0);
199
+ else if (input === 'G') setCursor(rows.length - 1);
200
+ else if (input === ']' || input === '[') {
201
+ // Jump to the first session of the next / previous directory.
202
+ const starts = [];
203
+ let n = 0;
204
+ for (const g of groups) { starts.push(n); n += g.sessions.length; }
205
+ const cur = starts.findLastIndex(st => st <= cursor);
206
+ const target = input === ']' ? starts[cur + 1] : (cursor > starts[cur] ? starts[cur] : starts[cur - 1]);
207
+ if (target !== undefined) setCursor(target);
208
+ }
209
+ else if (key.return) choose('resume');
210
+ else if (input === 'f') choose('fork');
211
+ else if (input === 'n') choose('new');
212
+ else if (input === 't' && sel && requireDir()) {
213
+ try {
214
+ const p = providerOf(sel);
215
+ const where = openInNewTab(p.command, sel.cwd, p.launchArgs('resume', sel, passthrough), oneLine(sel.title) || sel.id);
216
+ flash(`Opened "${oneLine(sel.title) || sel.id}" in a new ${where}`);
217
+ } catch (e) { flash(`Could not open a new tab: ${e.message}`, 'red'); }
218
+ }
219
+ else if (input === 'o' && sel && requireDir()) { openFolder(sel.cwd); flash(`Opened ${sel.cwd}`); }
220
+ else if (input === 'c' && sel) {
221
+ try { copyToClipboard(sel.id); flash(`Copied ${sel.id}`); } catch (e) { flash(`Copy failed: ${e.message}`, 'red'); }
222
+ }
223
+ else if (input === 'x' && sel) {
224
+ if (isMaybeLive(sel)) flash('Used in the last 10 minutes; it may still be open. Not archiving.', 'yellow');
225
+ else setMode('confirm-archive');
226
+ }
227
+ else if (input === 'X') setMode('prune-days');
228
+ else if (input === '/') setMode('filter');
229
+ else if (input === 's') { setSortIdx(i => (i + 1) % SORTS.length); setCursor(0); }
230
+ else if (key.tab) { setAgentIdx(i => (i + 1) % (AGENTS.length + 1)); setCursor(0); }
231
+ else if (input === 'a') { setShowScratch(v => !v); setCursor(0); }
232
+ else if (key.escape && filter) { setFilter(''); setDeepHits(null); setCursor(0); }
233
+ else if (input === 'q' || key.escape) exit();
234
+ });
235
+
236
+ const cTitle = Math.max(20, width - 4 - 2 - 14 - 10 - 10 - 7 - 5);
237
+ const header = pad('TITLE', cTitle) + ' ' + pad('BRANCH', 14) + ' '
238
+ + pad('CREATED', 10) + ' ' + pad('LAST USED', 10) + ' ' + pad('SIZE', 7);
239
+ const hiddenScratch = showScratch ? 0 : sessions.filter(s => s.scratch).length;
240
+ const agentCounts = AGENTS
241
+ .map(a => [a, rows.filter(s => s.agent === a).length])
242
+ .filter(([, n]) => n)
243
+ .map(([a, n]) => ` · ${n} ${a}`).join('');
244
+
245
+ const filterLine = h(Text, { wrap: 'truncate' },
246
+ h(Text, { color: mode === 'filter' ? 'yellow' : 'gray' }, deep ? 'full-text / ' : 'filter / '),
247
+ filter || (mode === 'filter' ? '' : h(Text, { dimColor: true }, 'press / to filter')),
248
+ mode === 'filter' ? h(Text, { inverse: true }, ' ') : null,
249
+ mode === 'filter' ? h(Text, { dimColor: true }, deep
250
+ ? ' tab: titles only · enter: search all transcripts'
251
+ : ' tab: search full transcripts') : null,
252
+ searching ? h(Text, { color: 'yellow' }, ' searching…') : null,
253
+ deep && deepHits && !searching ? h(Text, { dimColor: true }, ` ${deepHits.size} transcripts match`) : null,
254
+ );
255
+
256
+ let prompt = null;
257
+ if (mode === 'confirm-archive' && sel) {
258
+ prompt = h(Text, { color: 'yellow' }, `Archive "${oneLine(sel.title) || sel.id}"? It moves to the ${sel.agent} ccnav-archive folder and can be moved back. (y/N)`);
259
+ } else if (mode === 'prune-days') {
260
+ prompt = h(Text, { color: 'yellow' }, 'Archive sessions not used in the last ', h(Text, { inverse: true }, days || ' '),
261
+ ` days → ${pruneTargets.length} match (of those listed). enter to continue, esc to cancel`);
262
+ } else if (mode === 'confirm-prune') {
263
+ prompt = h(Text, { color: 'yellow' }, `Archive ${pruneTargets.length} sessions not used in ${days} days? (y/N)`);
264
+ }
265
+
266
+ const snippet = sel && deep && deepHits?.get(keyOf(sel));
267
+
268
+ return h(Box, { flexDirection: 'column', width },
269
+ h(Box, null,
270
+ h(Text, { bold: true, color: 'cyan' }, 'ccnav'),
271
+ h(Text, { dimColor: true },
272
+ ` ${rows.length} sessions${agentCounts} · sorted by ${SORTS[sortIdx].label}`
273
+ + (AGENTS[agentIdx - 1] ? ` · ${AGENTS[agentIdx - 1]} only (tab)` : '')
274
+ + (hiddenScratch ? ` · ${hiddenScratch} desktop scratch hidden (a)` : '')),
275
+ ),
276
+ filterLine,
277
+ h(Text, { bold: true, dimColor: true }, ' ' + header),
278
+ ...visible.map(it => {
279
+ if (it.kind === 'dir') {
280
+ return h(Text, { key: 'dir:' + it.group.cwd, bold: true, color: it.group.exists ? 'cyan' : 'red', wrap: 'wrap' }, it.label);
281
+ }
282
+ const s = it.session;
283
+ const p = providerOf(s);
284
+ const active = it.index === cursor;
285
+ const line = pad(oneLine(s.title || s.firstPrompt) || '(untitled)', cTitle) + ' '
286
+ + pad(s.branch, 14) + ' ' + pad(ago(s.created), 10) + ' ' + pad(ago(s.lastUsed), 10) + ' '
287
+ + pad(humanSize(s.size), 7);
288
+ return h(Text, { key: keyOf(s), inverse: active, color: s.cwdExists ? undefined : 'red', wrap: 'truncate' },
289
+ (active ? ' › ' : ' '),
290
+ h(Text, { color: s.cwdExists ? p.color : 'red' }, p.glyph + ' '),
291
+ line);
292
+ }),
293
+ rows.length === 0 ? h(Text, { dimColor: true }, searching ? ' searching…' : ' no sessions match') : null,
294
+ h(Box, { marginTop: 1, flexDirection: 'column', borderStyle: 'round', borderColor: 'gray', paddingX: 1 },
295
+ sel ? [
296
+ h(Text, { key: 'p', wrap: 'truncate' }, h(Text, { dimColor: true }, 'dir '), sel.cwd,
297
+ sel.cwdExists ? '' : h(Text, { color: 'red' }, ' (missing)')),
298
+ h(Text, { key: 'i', wrap: 'truncate' }, h(Text, { dimColor: true }, 'id '), sel.id,
299
+ h(Text, { dimColor: true }, ` · ${sel.agent}${sel.origin === 'desktop' ? ' (desktop/IDE)' : ''}`
300
+ + `${sel.version ? ' ' + sel.version : ''}`
301
+ + ` · started ${sel.created.toLocaleString()} · last ${sel.lastUsed.toLocaleString()}`)),
302
+ h(Text, { key: 'f', wrap: 'truncate' }, h(Text, { dimColor: true }, 'first '), oneLine(sel.firstPrompt)),
303
+ snippet
304
+ ? h(Text, { key: 'm', wrap: 'truncate' }, h(Text, { dimColor: true }, 'match '), h(Text, { color: 'yellow' }, '…' + snippet + '…'))
305
+ : h(Text, { key: 'l', wrap: 'truncate' }, h(Text, { dimColor: true }, pad(sel.lastLabel || 'last', 7)), oneLine(sel.lastText)),
306
+ ] : h(Text, { dimColor: true }, 'nothing selected'),
307
+ ),
308
+ prompt,
309
+ message ? h(Text, { color: message.color, wrap: 'truncate' }, message.text) : null,
310
+ h(Text, { dimColor: true, wrap: 'truncate' },
311
+ '↑↓ move [ ] prev/next dir tab agent enter resume t new tab f fork n new here o open dir c copy id / filter s sort x archive X archive old a scratch q quit'),
312
+ );
313
+ }
package/src/jsonl.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // Shared JSONL reading: transcripts reach tens of MB, so only the ends are read.
2
+ import fs from 'node:fs';
3
+
4
+ export const HEAD_BYTES = 256 * 1024;
5
+ export const TAIL_BYTES = 256 * 1024;
6
+
7
+ function readSlice(fd, start, length) {
8
+ const buf = Buffer.alloc(length);
9
+ const n = fs.readSync(fd, buf, 0, length, start);
10
+ return buf.subarray(0, n).toString('utf8');
11
+ }
12
+
13
+ export function parseLines(text, { dropFirst = false, dropLast = false } = {}) {
14
+ const lines = text.split('\n');
15
+ if (dropFirst) lines.shift();
16
+ if (dropLast) lines.pop();
17
+ const out = [];
18
+ for (const line of lines) {
19
+ if (!line.trim()) continue;
20
+ try { out.push(JSON.parse(line)); } catch { /* partial or corrupt line */ }
21
+ }
22
+ return out;
23
+ }
24
+
25
+ // Returns { stat, head, tail }; tail === head when the file fits in one read.
26
+ export function readEnds(file) {
27
+ const stat = fs.statSync(file);
28
+ const fd = fs.openSync(file, 'r');
29
+ try {
30
+ const headLen = Math.min(HEAD_BYTES, stat.size);
31
+ const head = parseLines(readSlice(fd, 0, headLen), { dropLast: headLen < stat.size });
32
+ const tailStart = Math.max(0, stat.size - TAIL_BYTES);
33
+ const tail = tailStart > headLen
34
+ ? parseLines(readSlice(fd, tailStart, stat.size - tailStart), { dropFirst: true })
35
+ : head;
36
+ return { stat, head, tail };
37
+ } finally {
38
+ fs.closeSync(fd);
39
+ }
40
+ }
41
+
42
+ // Text a person typed, with injected <system-reminder>-style wrappers removed.
43
+ export function humanText(text) {
44
+ return String(text || '').replace(/<([\w-]+)[^>]*>[\s\S]*?<\/\1>/g, '').trim();
45
+ }
@@ -0,0 +1,194 @@
1
+ // Everything that differs between Windows, macOS and Linux lives here.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+
7
+ export const IS_WIN = process.platform === 'win32';
8
+ export const IS_MAC = process.platform === 'darwin';
9
+
10
+ const has = cmd => IS_WIN
11
+ ? spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0
12
+ : spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' }).status === 0;
13
+
14
+ // POSIX single-quote escaping for building `sh -c` / AppleScript command lines.
15
+ const shq = s => `'${String(s).replace(/'/g, `'\\''`)}'`;
16
+ // Windows command lines: quote args with spaces, strip embedded quotes (args are UUIDs and flags).
17
+ const winq = s => /[\s"]/.test(s) ? `"${String(s).replace(/"/g, '')}"` : s;
18
+ const osaString = s => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
19
+
20
+ const INSTALL_HINT = {
21
+ claude: 'npm install -g @anthropic-ai/claude-code',
22
+ codex: 'npm install -g @openai/codex',
23
+ };
24
+
25
+ // Runs the agent in the foreground with the session's directory as cwd; resolves with its exit code.
26
+ export function launchAgent(cmd, cwd, args) {
27
+ return new Promise(resolve => {
28
+ // On Windows these are npm .cmd shims, which need a shell; elsewhere spawn them directly.
29
+ const child = IS_WIN
30
+ ? spawn([cmd, ...args.map(winq)].join(' '), { cwd, stdio: 'inherit', shell: true })
31
+ : spawn(cmd, args, { cwd, stdio: 'inherit' });
32
+ child.on('error', e => {
33
+ console.error(e.code === 'ENOENT'
34
+ ? `${cmd} was not found on PATH.${INSTALL_HINT[cmd] ? ` Install it: ${INSTALL_HINT[cmd]}` : ''}`
35
+ : `could not start ${cmd}: ${e.message}`);
36
+ resolve(127);
37
+ });
38
+ child.on('exit', code => resolve(code ?? 0));
39
+ });
40
+ }
41
+
42
+ // Opens a new terminal tab (or window) in `cwd` running `cmd` with `args`. Throws if nothing worked.
43
+ export function openInNewTab(cmd, cwd, args, title = cmd) {
44
+ const run = (cmd, argv) => {
45
+ const r = spawnSync(cmd, argv, { stdio: 'ignore' });
46
+ if (r.error || r.status !== 0) throw new Error(`${cmd} failed${r.error ? `: ${r.error.message}` : ` (exit ${r.status})`}`);
47
+ };
48
+
49
+ if (IS_WIN) {
50
+ const argv = ['-w', '0', 'new-tab', '--title', title.replace(/[;"]/g, ' ').slice(0, 40), '-d', cwd,
51
+ 'pwsh', '-NoExit', '-Command', cmd, ...args];
52
+ // wt.exe is an app execution alias, which needs a shell to launch reliably; it returns immediately.
53
+ const r = spawnSync(['wt.exe', ...argv.map(winq)].join(' '), { shell: true, stdio: 'ignore', windowsHide: true });
54
+ if (r.error || r.status !== 0) throw new Error(r.error?.message || `wt.exe exited with ${r.status}`);
55
+ return 'Windows Terminal tab';
56
+ }
57
+
58
+ const shellCmd = `cd ${shq(cwd)} && ${cmd} ${args.map(shq).join(' ')}`;
59
+ const term = process.env.TERM_PROGRAM || '';
60
+
61
+ if (term === 'WezTerm' && has('wezterm')) {
62
+ run('wezterm', ['cli', 'spawn', '--cwd', cwd, '--', cmd, ...args]);
63
+ return 'WezTerm tab';
64
+ }
65
+ if (process.env.KITTY_WINDOW_ID && has('kitty')) {
66
+ run('kitty', ['@', 'launch', '--type=tab', '--cwd', cwd, cmd, ...args]);
67
+ return 'kitty tab';
68
+ }
69
+ if (IS_MAC) {
70
+ if (term === 'iTerm.app') {
71
+ run('osascript', [
72
+ '-e', 'tell application "iTerm" to tell current window to create tab with default profile',
73
+ '-e', `tell application "iTerm" to tell current session of current window to write text ${osaString(shellCmd)}`,
74
+ ]);
75
+ return 'iTerm tab';
76
+ }
77
+ // Terminal.app (and anything else): a new Terminal window. Real tabs would need Accessibility permission.
78
+ run('osascript', ['-e', `tell application "Terminal" to do script ${osaString(shellCmd)}`,
79
+ '-e', 'tell application "Terminal" to activate']);
80
+ return 'Terminal window';
81
+ }
82
+ if (has('gnome-terminal')) {
83
+ run('gnome-terminal', ['--tab', `--working-directory=${cwd}`, '--', cmd, ...args]);
84
+ return 'GNOME Terminal tab';
85
+ }
86
+ if (has('konsole')) {
87
+ run('konsole', ['--new-tab', '--workdir', cwd, '-e', cmd, ...args]);
88
+ return 'Konsole tab';
89
+ }
90
+ throw new Error('no supported terminal found (Windows Terminal, iTerm2, Terminal.app, WezTerm, kitty, GNOME Terminal, Konsole)');
91
+ }
92
+
93
+ export function openFolder(dir) {
94
+ const cmd = IS_WIN ? 'explorer.exe' : IS_MAC ? 'open' : 'xdg-open';
95
+ const child = spawn(cmd, [dir], { detached: true, stdio: 'ignore' });
96
+ child.on('error', () => {});
97
+ child.unref();
98
+ }
99
+
100
+ export function copyToClipboard(text) {
101
+ const candidates = IS_WIN ? [['clip', []]]
102
+ : IS_MAC ? [['pbcopy', []]]
103
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
104
+ for (const [cmd, argv] of candidates) {
105
+ const r = spawnSync(cmd, argv, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
106
+ if (!r.error && r.status === 0) return;
107
+ }
108
+ throw new Error('no clipboard tool found');
109
+ }
110
+
111
+ // ---- shell integration: a wrapper function that cds into the directory ccnav reports ----
112
+
113
+ const BEGIN = '# >>> ccnav >>>';
114
+ const END = '# <<< ccnav <<<';
115
+
116
+ const POWERSHELL_FN = `
117
+ # Wraps the ccnav npm shim so this shell ends up in the chosen session's directory.
118
+ function ccnav {
119
+ $shim = Join-Path $env:APPDATA 'npm\\ccnav.ps1'
120
+ if (-not (Test-Path -LiteralPath $shim)) { Write-Warning 'ccnav is not installed on this machine'; return }
121
+ $cwdFile = [IO.Path]::GetTempFileName()
122
+ $env:CCNAV_CWD_FILE = $cwdFile
123
+ try { & $shim @args }
124
+ finally {
125
+ Remove-Item Env:CCNAV_CWD_FILE -ErrorAction Ignore
126
+ $dir = Get-Content -LiteralPath $cwdFile -Raw -ErrorAction Ignore
127
+ Remove-Item -LiteralPath $cwdFile -ErrorAction Ignore
128
+ if ($dir -and (Test-Path -LiteralPath $dir.Trim())) { Set-Location -LiteralPath $dir.Trim() }
129
+ }
130
+ }
131
+ `;
132
+
133
+ // Works in both bash and zsh. `command` skips the function; the subshell check avoids finding it too.
134
+ const POSIX_FN = `
135
+ # Wraps the ccnav binary so this shell ends up in the chosen session's directory.
136
+ ccnav() {
137
+ if ! (unset -f ccnav; command -v ccnav >/dev/null 2>&1); then
138
+ echo "ccnav: not installed on this machine" >&2; return 1
139
+ fi
140
+ local f rc dir
141
+ f=$(mktemp "\${TMPDIR:-/tmp}/ccnav.XXXXXX") || return 1
142
+ CCNAV_CWD_FILE="$f" command ccnav "$@"
143
+ rc=$?
144
+ dir=$(cat "$f" 2>/dev/null); rm -f "$f"
145
+ if [ -n "$dir" ] && [ -d "$dir" ]; then cd "$dir"; fi
146
+ return $rc
147
+ }
148
+ `;
149
+
150
+ const FISH_FN = `
151
+ function ccnav --description 'Resume a Claude Code or Codex session in its directory'
152
+ if not command -q ccnav
153
+ echo "ccnav: not installed on this machine" >&2; return 1
154
+ end
155
+ set -l f (mktemp)
156
+ env CCNAV_CWD_FILE=$f command ccnav $argv
157
+ set -l rc $status
158
+ set -l dir (cat $f 2>/dev/null); rm -f $f
159
+ if test -n "$dir"; and test -d "$dir"; cd $dir; end
160
+ return $rc
161
+ end
162
+ `;
163
+
164
+ function shellTarget() {
165
+ if (IS_WIN) {
166
+ const r = spawnSync('pwsh', ['-NoProfile', '-Command', '$PROFILE'], { encoding: 'utf8' });
167
+ const file = r.stdout?.trim() || path.join(os.homedir(), 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
168
+ return { shell: 'powershell', file, body: POWERSHELL_FN };
169
+ }
170
+ const shell = path.basename(process.env.SHELL || (IS_MAC ? 'zsh' : 'bash'));
171
+ if (shell === 'fish') {
172
+ return { shell, file: path.join(os.homedir(), '.config', 'fish', 'functions', 'ccnav.fish'), body: FISH_FN };
173
+ }
174
+ if (shell === 'zsh') {
175
+ return { shell, file: path.join(process.env.ZDOTDIR || os.homedir(), '.zshrc'), body: POSIX_FN };
176
+ }
177
+ // macOS login shells read .bash_profile; Linux interactive shells read .bashrc.
178
+ return { shell: 'bash', file: path.join(os.homedir(), IS_MAC ? '.bash_profile' : '.bashrc'), body: POSIX_FN };
179
+ }
180
+
181
+ // Idempotent: replaces an existing ccnav block instead of appending a second one.
182
+ export function installShellFunction() {
183
+ const { shell, file, body } = shellTarget();
184
+ const block = `${BEGIN}${body}${END}`;
185
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
186
+ const re = new RegExp(`${BEGIN}[\\s\\S]*?${END}`);
187
+ const next = re.test(current)
188
+ ? current.replace(re, () => block)
189
+ : current.replace(/\s*$/, '') + (current ? '\n\n' : '') + block + '\n';
190
+ fs.mkdirSync(path.dirname(file), { recursive: true });
191
+ fs.writeFileSync(file, next, 'utf8');
192
+ const reload = shell === 'powershell' ? '. $PROFILE' : shell === 'fish' ? 'open a new fish shell' : `source ${file.replace(os.homedir(), '~')}`;
193
+ return { shell, file, reload };
194
+ }
@@ -0,0 +1,90 @@
1
+ // Claude Code sessions: ~/.claude/projects/<encoded-dir>/<session-id>.jsonl
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { humanText, readEnds } from '../jsonl.mjs';
6
+
7
+ export const agent = 'claude';
8
+ export const command = 'claude';
9
+ export const glyph = '◆';
10
+ export const color = 'cyan';
11
+
12
+ export const HOME_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
13
+ export const SESSIONS_ROOT = path.join(HOME_DIR, 'projects');
14
+ export const ARCHIVE_ROOT = path.join(HOME_DIR, 'ccnav-archive');
15
+
16
+ function promptText(r) {
17
+ if (r.type !== 'user' || r.isMeta || r.isSidechain) return '';
18
+ const c = r.message?.content;
19
+ const text = typeof c === 'string' ? c
20
+ : Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join(' ') : '';
21
+ return humanText(text);
22
+ }
23
+
24
+ export function readSession(file) {
25
+ const { stat, head, tail } = readEnds(file);
26
+ const firstMsg = head.find(r => r.cwd && r.timestamp);
27
+ const firstPrompt = head.map(promptText).find(Boolean);
28
+ let title, lastPrompt, lastTs, bridge, lastCwd, branch;
29
+ for (const r of tail) {
30
+ if (r.type === 'ai-title' && r.aiTitle) title = r.aiTitle;
31
+ if (r.type === 'custom-title' && r.customTitle) title = r.customTitle;
32
+ if (r.type === 'last-prompt' && r.lastPrompt) lastPrompt = r.lastPrompt;
33
+ if (r.timestamp) lastTs = r.timestamp;
34
+ if (r.cwd) lastCwd = r.cwd;
35
+ if (r.gitBranch) branch = r.gitBranch;
36
+ }
37
+ for (const r of head) if (r.type === 'bridge-session' && r.bridgeSessionId) bridge = r.bridgeSessionId;
38
+ if (!title) for (const r of head) if (r.type === 'ai-title' && r.aiTitle) title = r.aiTitle;
39
+
40
+ const cwd = firstMsg?.cwd || lastCwd;
41
+ if (!cwd) return null; // no conversation ever happened
42
+
43
+ const id = path.basename(file, '.jsonl');
44
+ return {
45
+ agent, id, file,
46
+ // Subagent sidechains live in a folder named after the session; archive it alongside.
47
+ extraPaths: [path.join(path.dirname(file), id)],
48
+ root: SESSIONS_ROOT,
49
+ archiveRoot: ARCHIVE_ROOT,
50
+ cwd,
51
+ cwdExists: fs.existsSync(cwd),
52
+ branch: (b => b === 'HEAD' ? '' : b)(branch || firstMsg?.gitBranch || ''),
53
+ version: firstMsg?.version || '',
54
+ origin: 'cli',
55
+ title: title || '',
56
+ firstPrompt: firstPrompt || '',
57
+ lastText: lastPrompt || '',
58
+ lastLabel: 'last',
59
+ bridgeSessionId: bridge || '',
60
+ created: new Date(firstMsg?.timestamp || stat.birthtime),
61
+ lastUsed: lastTs ? new Date(Math.max(new Date(lastTs), stat.mtime)) : stat.mtime,
62
+ size: stat.size,
63
+ // Claude Desktop scratch folders: %APPDATA%\Claude\... on Windows, ~/Library/Application Support/Claude/... on macOS.
64
+ scratch: /[\\/]Claude[\\/]scratch-workspaces[\\/]/i.test(cwd),
65
+ };
66
+ }
67
+
68
+ // Top-level *.jsonl only: <sessionId>/ subdirectories hold subagent sidechains.
69
+ export function listSessions() {
70
+ if (!fs.existsSync(SESSIONS_ROOT)) return [];
71
+ const out = [];
72
+ for (const dir of fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true })) {
73
+ if (!dir.isDirectory()) continue;
74
+ const projectDir = path.join(SESSIONS_ROOT, dir.name);
75
+ for (const f of fs.readdirSync(projectDir)) {
76
+ if (!f.endsWith('.jsonl')) continue;
77
+ try {
78
+ const s = readSession(path.join(projectDir, f));
79
+ if (s) out.push(s);
80
+ } catch { /* unreadable file */ }
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ export function launchArgs(action, session, passthrough = []) {
87
+ if (action === 'fork') return ['--resume', session.id, '--fork-session', ...passthrough];
88
+ if (action === 'new') return [...passthrough];
89
+ return ['--resume', session.id, ...passthrough];
90
+ }
@@ -0,0 +1,110 @@
1
+ // Codex CLI sessions: ~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<uuid>.jsonl
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { humanText, readEnds } from '../jsonl.mjs';
6
+
7
+ export const agent = 'codex';
8
+ export const command = 'codex';
9
+ export const glyph = '●';
10
+ export const color = 'green';
11
+
12
+ export const HOME_DIR = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
13
+ export const SESSIONS_ROOT = path.join(HOME_DIR, 'sessions');
14
+ export const ARCHIVE_ROOT = path.join(HOME_DIR, 'ccnav-archive');
15
+ const INDEX_FILE = path.join(HOME_DIR, 'session_index.jsonl');
16
+
17
+ // session_index.jsonl names about half the sessions; read it once per listing.
18
+ function readIndex() {
19
+ const map = new Map();
20
+ let text;
21
+ try { text = fs.readFileSync(INDEX_FILE, 'utf8'); } catch { return map; }
22
+ for (const line of text.split('\n')) {
23
+ if (!line.trim()) continue;
24
+ try {
25
+ const { id, thread_name, updated_at } = JSON.parse(line);
26
+ if (id) map.set(id, { title: thread_name || '', updatedAt: updated_at ? new Date(updated_at) : null });
27
+ } catch { /* skip */ }
28
+ }
29
+ return map;
30
+ }
31
+
32
+ const textOf = content => (Array.isArray(content) ? content : [])
33
+ .filter(c => c.type === 'input_text' || c.type === 'text' || c.type === 'output_text')
34
+ .map(c => c.text).join(' ');
35
+
36
+ export function readSession(file, index = readIndex()) {
37
+ const { stat, head, tail } = readEnds(file);
38
+ const meta = head.find(r => r.type === 'session_meta')?.payload;
39
+ if (!meta?.cwd) return null;
40
+
41
+ const firstPrompt = head
42
+ .filter(r => r.type === 'response_item' && r.payload?.type === 'message' && r.payload.role === 'user')
43
+ .map(r => humanText(textOf(r.payload.content)))
44
+ .find(Boolean) || '';
45
+
46
+ let lastText = '', lastTs;
47
+ for (const r of tail) {
48
+ if (r.timestamp) lastTs = r.timestamp;
49
+ const p = r.payload;
50
+ if (p?.type === 'task_complete' && p.last_agent_message) lastText = p.last_agent_message;
51
+ else if (p?.type === 'agent_message' && p.message) lastText = p.message;
52
+ }
53
+
54
+ const id = meta.session_id || meta.id;
55
+ const indexed = index.get(id);
56
+ const stamps = [stat.mtime, lastTs && new Date(lastTs), indexed?.updatedAt].filter(Boolean);
57
+ // Desktop and IDE sessions are stored here too; the CLI can still resume them.
58
+ const origin = meta.source === 'cli' || /cli/i.test(meta.originator || '') ? 'cli' : 'desktop';
59
+
60
+ return {
61
+ agent, id, file,
62
+ extraPaths: [],
63
+ root: SESSIONS_ROOT,
64
+ archiveRoot: ARCHIVE_ROOT,
65
+ cwd: meta.cwd,
66
+ cwdExists: fs.existsSync(meta.cwd),
67
+ branch: '',
68
+ version: meta.cli_version || '',
69
+ origin,
70
+ // A session spawned by another session, like a Claude sidechain.
71
+ subagent: !!(meta.source && typeof meta.source === 'object' && meta.source.subagent),
72
+ title: indexed?.title || '',
73
+ firstPrompt,
74
+ lastText: humanText(lastText),
75
+ lastLabel: 'reply',
76
+ created: new Date(meta.timestamp || head[0]?.timestamp || stat.birthtime),
77
+ lastUsed: new Date(Math.max(...stamps.map(Number))),
78
+ size: stat.size,
79
+ scratch: false,
80
+ };
81
+ }
82
+
83
+ // sessions/YYYY/MM/DD/*.jsonl
84
+ export function listSessions() {
85
+ if (!fs.existsSync(SESSIONS_ROOT)) return [];
86
+ const index = readIndex();
87
+ const out = [];
88
+ const walk = (dir, depth) => {
89
+ let entries;
90
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
91
+ for (const e of entries) {
92
+ const p = path.join(dir, e.name);
93
+ if (e.isDirectory() && depth < 3) walk(p, depth + 1);
94
+ else if (e.isFile() && e.name.endsWith('.jsonl')) {
95
+ try {
96
+ const s = readSession(p, index);
97
+ if (s && !s.subagent) out.push(s);
98
+ } catch { /* unreadable file */ }
99
+ }
100
+ }
101
+ };
102
+ walk(SESSIONS_ROOT, 0);
103
+ return out;
104
+ }
105
+
106
+ export function launchArgs(action, session, passthrough = []) {
107
+ if (action === 'fork') return ['fork', session.id, ...passthrough];
108
+ if (action === 'new') return [...passthrough];
109
+ return ['resume', session.id, ...passthrough];
110
+ }
@@ -0,0 +1,40 @@
1
+ import os from 'node:os';
2
+ import * as claude from './providers/claude.mjs';
3
+ import * as codex from './providers/codex.mjs';
4
+
5
+ export const PROVIDERS = { claude, codex };
6
+ export const AGENTS = Object.keys(PROVIDERS);
7
+
8
+ // Both agents' sessions in one list, newest use first.
9
+ export function listSessions({ agents = AGENTS } = {}) {
10
+ const out = [];
11
+ for (const name of agents) {
12
+ try { out.push(...PROVIDERS[name].listSessions()); } catch { /* provider not installed */ }
13
+ }
14
+ return out.sort((a, b) => b.lastUsed - a.lastUsed);
15
+ }
16
+
17
+ export function providerOf(session) {
18
+ return PROVIDERS[session.agent] || PROVIDERS.claude;
19
+ }
20
+
21
+ export function ago(date, now = Date.now()) {
22
+ const s = Math.max(0, (now - date) / 1000);
23
+ if (s < 60) return 'just now';
24
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
25
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
26
+ if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
27
+ if (s < 86400 * 365) return `${Math.floor(s / 86400 / 30)}mo ago`;
28
+ return `${Math.floor(s / 86400 / 365)}y ago`;
29
+ }
30
+
31
+ export function humanSize(n) {
32
+ if (n < 1024) return `${n}B`;
33
+ if (n < 1024 ** 2) return `${Math.round(n / 1024)}K`;
34
+ return `${(n / 1024 ** 2).toFixed(1)}M`;
35
+ }
36
+
37
+ export function shortPath(p) {
38
+ const home = os.homedir();
39
+ return p.toLowerCase().startsWith(home.toLowerCase()) ? '~' + p.slice(home.length) : p;
40
+ }