ccnav 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,8 @@ npm install -g ./ccnav-1.0.0.tgz
34
34
 
35
35
  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. On macOS the first new tab asks for permission to control the terminal; if it is refused, allow it again under System Settings → Privacy & Security → Automation.
36
36
 
37
+ On Windows, PowerShell 7 preserves forwarded arguments including quotes and shell characters. With the built-in Windows PowerShell 5.1 fallback, arguments containing quotes, shell characters or line breaks are rejected because its npm command shim cannot pass them safely.
38
+
37
39
  ## Agents
38
40
 
39
41
  Sessions are listed whether or not the agent that wrote them is still installed, so an
@@ -120,7 +122,7 @@ Codex removed `--full-auto` in 0.156, so the sandboxed middle ground is now spel
120
122
  | Resume / fork | `claude --resume <id>` / `--fork-session` | `codex resume <id>` / `codex fork <id>` |
121
123
  | Hidden | subagent sidechain folders, Desktop scratch workspaces | subagent threads spawned by other sessions |
122
124
 
123
- 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.
125
+ 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 streams each file in full and can be cancelled while it runs.
124
126
 
125
127
  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.
126
128
 
package/bin/ccnav.mjs CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccnav",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Claude Codex Navigator - pick a Claude Code or Codex session and resume it in its directory",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -19,6 +19,7 @@
19
19
  "ccnav": "bin/ccnav.mjs"
20
20
  },
21
21
  "dependencies": {
22
+ "cross-spawn": "^7.0.6",
22
23
  "ink": "^7.1.1",
23
24
  "react": "^19.3.0"
24
25
  },
package/src/actions.mjs CHANGED
@@ -1,31 +1,40 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { setImmediate as yieldToUi } from 'node:timers/promises';
3
5
  export { askUser, copyToClipboard, ensureAgent, hasCommand, installShellFunction, launchAgent, openFolder, openInNewTab } from './platform.mjs';
4
6
 
5
7
  // Sessions touched this recently may be open in another terminal.
6
8
  export const LIVE_WINDOW_MS = 10 * 60 * 1000;
7
9
  export const isMaybeLive = s => Date.now() - s.lastUsed < LIVE_WINDOW_MS;
8
10
 
9
- // Case-insensitive search through the whole transcript; returns a short snippet or null.
10
- export function searchTranscript(session, query) {
11
+ // Stream the transcript so a search does not block the picker or load whole files into memory.
12
+ export async function searchTranscript(session, query, signal) {
13
+ if (!query || signal?.aborted) return null;
11
14
  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);
15
+ const stream = fs.createReadStream(session.file, { encoding: 'utf8', signal });
16
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
21
17
  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);
18
+ let count = 0;
19
+ for await (const line of lines) {
20
+ if (signal?.aborted) return null;
21
+ if (++count % 256 === 0) await yieldToUi();
22
+ const idx = line.toLowerCase().indexOf(q);
23
+ if (idx < 0) continue;
24
+ // Prefer the matching string value from the parsed record over raw JSON.
25
+ let hay = line.slice(Math.max(0, idx - 60), idx + q.length + 80);
26
+ try {
27
+ const found = findString(JSON.parse(line), q);
28
+ if (found) {
29
+ const i = found.toLowerCase().indexOf(q);
30
+ hay = found.slice(Math.max(0, i - 60), i + q.length + 80);
31
+ }
32
+ } catch { /* fall back to the raw line */ }
33
+ return hay.replace(/\\[nrt]/g, ' ').replace(/\s+/g, ' ').trim();
26
34
  }
27
- } catch { /* fall back to raw slice */ }
28
- return hay.replace(/\\[nrt]/g, ' ').replace(/\s+/g, ' ').trim();
35
+ } catch { /* unreadable or cancelled transcript */ }
36
+ finally { lines.close(); stream.destroy(); }
37
+ return null;
29
38
  }
30
39
 
31
40
  function findString(v, q) {
package/src/app.mjs CHANGED
@@ -34,27 +34,33 @@ export function App({ sessions: initialSessions, onChoose, passthrough = [] }) {
34
34
  const [mode, setMode] = useState('list');
35
35
  const [deep, setDeep] = useState(false);
36
36
  const [deepHits, setDeepHits] = useState(null); // Map id -> snippet, for the last deep query
37
- const [searching, setSearching] = useState(false);
37
+ const [searchQuery, setSearchQuery] = useState(null);
38
38
  const [showScratch, setShowScratch] = useState(false);
39
39
  const [agentIdx, setAgentIdx] = useState(0); // 0 = all agents, then one per agent
40
40
  const [message, setMessage] = useState(null); // { text, color }
41
41
  const [days, setDays] = useState('30');
42
42
 
43
- // Deep search reads whole transcripts, so it runs once per submitted query rather than per keystroke.
43
+ // Search once per submitted query, cancelling when the query or session list changes.
44
44
  useEffect(() => {
45
- if (!searching) return;
46
- const t = setTimeout(() => {
45
+ if (searchQuery === null) return;
46
+ const controller = new AbortController();
47
+ const search = async () => {
47
48
  const hits = new Map();
48
49
  for (const s of sessions) {
49
- const snip = searchTranscript(s, filter);
50
+ if (controller.signal.aborted) return;
51
+ const snip = await searchTranscript(s, searchQuery, controller.signal);
50
52
  if (snip) hits.set(keyOf(s), snip);
51
53
  }
54
+ if (controller.signal.aborted) return;
52
55
  setDeepHits(hits);
53
- setSearching(false);
56
+ setSearchQuery(null);
54
57
  setCursor(0);
55
- }, 10);
56
- return () => clearTimeout(t);
57
- }, [searching]);
58
+ };
59
+ search();
60
+ return () => controller.abort();
61
+ }, [searchQuery, sessions]);
62
+
63
+ const searching = searchQuery !== null;
58
64
 
59
65
  const sorted = useMemo(() => {
60
66
  const q = filter.toLowerCase();
@@ -162,11 +168,11 @@ export function App({ sessions: initialSessions, onChoose, passthrough = [] }) {
162
168
  setMessage(null);
163
169
 
164
170
  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); }
171
+ if (key.escape) { setMode('list'); setFilter(''); setDeepHits(null); setSearchQuery(null); return; }
172
+ if (key.tab) { setDeep(d => !d); setDeepHits(null); setSearchQuery(null); return; }
173
+ if (key.return) { setMode('list'); if (deep && filter) { setDeepHits(null); setSearchQuery(filter); } return; }
174
+ if (key.backspace || key.delete) { setFilter(f => f.slice(0, -1)); setDeepHits(null); setSearchQuery(null); setCursor(0); return; }
175
+ if (input && !key.ctrl && !key.meta) { setFilter(f => f + input); setDeepHits(null); setSearchQuery(null); setCursor(0); }
170
176
  return;
171
177
  }
172
178
 
@@ -234,7 +240,7 @@ export function App({ sessions: initialSessions, onChoose, passthrough = [] }) {
234
240
  else if (input === 's') { setSortIdx(i => (i + 1) % SORTS.length); setCursor(0); }
235
241
  else if (key.tab) { setAgentIdx(i => (i + 1) % (AGENTS.length + 1)); setCursor(0); }
236
242
  else if (input === 'a') { setShowScratch(v => !v); setCursor(0); }
237
- else if (key.escape && filter) { setFilter(''); setDeepHits(null); setCursor(0); }
243
+ else if (key.escape && filter) { setFilter(''); setDeepHits(null); setSearchQuery(null); setCursor(0); }
238
244
  else if (input === 'q' || key.escape) exit();
239
245
  });
240
246
 
package/src/jsonl.mjs CHANGED
@@ -25,14 +25,17 @@ export function parseLines(text, { dropFirst = false, dropLast = false } = {}) {
25
25
  // Returns { stat, head, tail }; tail === head when the file fits in one read.
26
26
  export function readEnds(file) {
27
27
  const stat = fs.statSync(file);
28
+ if (!stat.isFile()) throw new Error('not a regular file');
28
29
  const fd = fs.openSync(file, 'r');
29
30
  try {
30
31
  const headLen = Math.min(HEAD_BYTES, stat.size);
31
32
  const head = parseLines(readSlice(fd, 0, headLen), { dropLast: headLen < stat.size });
32
33
  const tailStart = Math.max(0, stat.size - TAIL_BYTES);
33
- const tail = tailStart > headLen
34
- ? parseLines(readSlice(fd, tailStart, stat.size - tailStart), { dropFirst: true })
34
+ const tail = stat.size > HEAD_BYTES
35
+ ? parseLines(readSlice(fd, tailStart, stat.size - tailStart),
36
+ { dropFirst: tailStart > 0 && readSlice(fd, tailStart - 1, 1) !== '\n' })
35
37
  : head;
38
+ if (stat.size && !head.length && !tail.length) throw new Error('no readable JSONL records');
36
39
  return { stat, head, tail };
37
40
  } finally {
38
41
  fs.closeSync(fd);
package/src/platform.mjs CHANGED
@@ -4,6 +4,8 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import readline from 'node:readline';
6
6
  import { spawn, spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import crossSpawn from 'cross-spawn';
7
9
 
8
10
  export const IS_WIN = process.platform === 'win32';
9
11
  export const IS_MAC = process.platform === 'darwin';
@@ -17,9 +19,29 @@ export const hasCommand = has;
17
19
 
18
20
  // POSIX single-quote escaping for building `sh -c` / AppleScript command lines.
19
21
  const shq = s => `'${String(s).replace(/'/g, `'\\''`)}'`;
20
- // Windows command lines: quote args with spaces, strip embedded quotes (args are UUIDs and flags).
21
- const winq = s => /[\s"]/.test(s) ? `"${String(s).replace(/"/g, '')}"` : s;
22
22
  const osaString = s => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
23
+ // Encode the command and argv as data before handing them to PowerShell.
24
+ function windowsCommand(cmd, args, { keepOpen = false } = {}) {
25
+ const spec = Buffer.from(JSON.stringify({ command: cmd, args }), 'utf8').toString('base64');
26
+ const run = `$ErrorActionPreference = 'Stop'; $spec = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${spec}')) | ConvertFrom-Json; $arguments = [string[]]$spec.args; $global:LASTEXITCODE = 0; try { & $spec.command @arguments } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 127 }`;
27
+ const script = keepOpen ? run : `${run}; exit $LASTEXITCODE`;
28
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
29
+ const shell = has('pwsh') ? 'pwsh' : 'powershell.exe';
30
+ return [shell, ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', ...(keepOpen ? ['-NoExit'] : []), '-EncodedCommand', encoded]];
31
+ }
32
+
33
+ // PowerShell 5 changes quoted arguments when its npm .ps1 shim forwards them to Node.
34
+ // For that fallback, use the .cmd shim and reject values it can reinterpret.
35
+ function legacyWindowsArgs(cmd, args) {
36
+ if (args.some(arg => /\0/.test(String(arg)))) return null;
37
+ const extension = path.extname(cmd).toLowerCase();
38
+ const where = spawnSync('where', [cmd], { encoding: 'utf8' });
39
+ const found = String(where.stdout || '').split(/\r?\n/);
40
+ const cmdShim = extension === '.cmd' || extension === '.bat'
41
+ || (extension !== '.exe' && found.some(p => /\.(cmd|bat)$/i.test(p)));
42
+ if (cmdShim && args.some(arg => /[\r\n"%!^&|<>]/.test(String(arg)))) return null;
43
+ return args;
44
+ }
23
45
 
24
46
  export const INSTALL_PKG = {
25
47
  claude: '@anthropic-ai/claude-code',
@@ -30,16 +52,24 @@ const hint = cmd => INSTALL_PKG[cmd] ? ` Install it: npm install -g ${INSTALL_PK
30
52
  // Runs the agent in the foreground with the session's directory as cwd; resolves with its exit code.
31
53
  export function launchAgent(cmd, cwd, args) {
32
54
  return new Promise(resolve => {
33
- // On Windows these are npm .cmd shims, which need a shell; elsewhere spawn them directly.
34
- const child = IS_WIN
35
- ? spawn([cmd, ...args.map(winq)].join(' '), { cwd, stdio: 'inherit', shell: true })
36
- : spawn(cmd, args, { cwd, stdio: 'inherit' });
37
- child.on('error', e => {
55
+ const legacy = IS_WIN && !has('pwsh');
56
+ const passedArgs = legacy ? legacyWindowsArgs(cmd, args) : args;
57
+ if (passedArgs === null) {
58
+ console.error('could not start agent: Windows PowerShell 5 cannot safely pass these arguments through a command shim; use PowerShell 7');
59
+ resolve(127);
60
+ return;
61
+ }
62
+ const [shell, shellArgs] = IS_WIN && !legacy ? windowsCommand(cmd, args) : [cmd, passedArgs];
63
+ const fail = e => {
38
64
  console.error(e.code === 'ENOENT'
39
- ? `${cmd} was not found on PATH.${hint(cmd)}`
65
+ ? `${shell} was not found on PATH.${shell === cmd ? hint(cmd) : ''}`
40
66
  : `could not start ${cmd}: ${e.message}`);
41
67
  resolve(127);
42
- });
68
+ };
69
+ let child;
70
+ try { child = legacy ? crossSpawn(shell, shellArgs, { cwd, stdio: 'inherit' }) : spawn(shell, shellArgs, { cwd, stdio: 'inherit' }); }
71
+ catch (error) { fail(error); return; }
72
+ child.on('error', fail);
43
73
  child.on('exit', code => resolve(code ?? 0));
44
74
  });
45
75
  }
@@ -85,7 +115,10 @@ export async function ensureAgent(cmd) {
85
115
  }
86
116
 
87
117
  console.log(`\nnpm install -g ${pkg}\n`);
88
- const r = spawnSync(IS_WIN ? 'npm.cmd' : 'npm', ['install', '-g', pkg], { stdio: 'inherit', shell: IS_WIN });
118
+ const [installer, installArgs] = IS_WIN
119
+ ? windowsCommand('npm', ['install', '-g', pkg])
120
+ : ['npm', ['install', '-g', pkg]];
121
+ const r = spawnSync(installer, installArgs, { stdio: 'inherit' });
89
122
  if (r.error || r.status !== 0) {
90
123
  console.error(`\nInstall failed${r.error ? `: ${r.error.message}` : ` (npm exited with ${r.status})`}.`);
91
124
  console.error(`If it was a permissions error, npm's global folder is not writable by you.`);
@@ -124,10 +157,13 @@ function macAutomationError(e, app) {
124
157
  // Opens a new terminal tab (or window) in `cwd` running `cmd` with `args`. Throws if nothing worked.
125
158
  export function openInNewTab(cmd, cwd, args, title = cmd) {
126
159
  if (IS_WIN) {
127
- const argv = ['-w', '0', 'new-tab', '--title', title.replace(/[;"]/g, ' ').slice(0, 40), '-d', cwd,
128
- 'pwsh', '-NoExit', '-Command', cmd, ...args];
129
- // wt.exe is an app execution alias, which needs a shell to launch reliably; it returns immediately.
130
- const r = spawnSync(['wt.exe', ...argv.map(winq)].join(' '), { shell: true, stdio: 'ignore', windowsHide: true });
160
+ const spec = Buffer.from(JSON.stringify({ cmd, cwd, args }), 'utf8').toString('base64');
161
+ const helper = fileURLToPath(new URL('./windows-tab.mjs', import.meta.url));
162
+ const [agentShell, agentArgs] = windowsCommand(process.execPath, [helper, spec], { keepOpen: true });
163
+ const argv = ['-w', '0', 'new-tab', '--title', title.replace(/[\r\n;"]/g, ' ').slice(0, 40), '-d', cwd,
164
+ agentShell, ...agentArgs];
165
+ const [terminalShell, terminalArgs] = windowsCommand('wt.exe', argv);
166
+ const r = spawnSync(terminalShell, terminalArgs, { stdio: 'ignore', windowsHide: true });
131
167
  if (r.error || r.status !== 0) throw new Error(r.error?.message || `wt.exe exited with ${r.status}`);
132
168
  return 'Windows Terminal tab';
133
169
  }
@@ -68,18 +68,22 @@ export function readSession(file) {
68
68
  }
69
69
 
70
70
  // Top-level *.jsonl only: <sessionId>/ subdirectories hold subagent sidechains.
71
- export function listSessions() {
71
+ export function listSessions({ onError = (file, error) => console.error(`ccnav: could not read Claude session at ${file}: ${error.message}`) } = {}) {
72
72
  if (!fs.existsSync(SESSIONS_ROOT)) return [];
73
73
  const out = [];
74
74
  for (const dir of fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true })) {
75
75
  if (!dir.isDirectory()) continue;
76
76
  const projectDir = path.join(SESSIONS_ROOT, dir.name);
77
- for (const f of fs.readdirSync(projectDir)) {
77
+ let files;
78
+ try { files = fs.readdirSync(projectDir); }
79
+ catch (error) { onError(projectDir, error); continue; }
80
+ for (const f of files) {
78
81
  if (!f.endsWith('.jsonl')) continue;
82
+ const file = path.join(projectDir, f);
79
83
  try {
80
- const s = readSession(path.join(projectDir, f));
84
+ const s = readSession(file);
81
85
  if (s) out.push(s);
82
- } catch { /* unreadable file */ }
86
+ } catch (error) { onError(file, error); }
83
87
  }
84
88
  }
85
89
  return out;
@@ -18,10 +18,14 @@ export const ARCHIVE_ROOT = path.join(HOME_DIR, 'ccnav-archive');
18
18
  const INDEX_FILE = path.join(HOME_DIR, 'session_index.jsonl');
19
19
 
20
20
  // session_index.jsonl names about half the sessions; read it once per listing.
21
- function readIndex() {
21
+ function readIndex(onError = () => {}) {
22
22
  const map = new Map();
23
23
  let text;
24
- try { text = fs.readFileSync(INDEX_FILE, 'utf8'); } catch { return map; }
24
+ try { text = fs.readFileSync(INDEX_FILE, 'utf8'); }
25
+ catch (error) {
26
+ if (error.code !== 'ENOENT') onError(INDEX_FILE, error);
27
+ return map;
28
+ }
25
29
  for (const line of text.split('\n')) {
26
30
  if (!line.trim()) continue;
27
31
  try {
@@ -84,13 +88,14 @@ export function readSession(file, index = readIndex()) {
84
88
  }
85
89
 
86
90
  // sessions/YYYY/MM/DD/*.jsonl
87
- export function listSessions() {
91
+ export function listSessions({ onError = (file, error) => console.error(`ccnav: could not read Codex session at ${file}: ${error.message}`) } = {}) {
88
92
  if (!fs.existsSync(SESSIONS_ROOT)) return [];
89
- const index = readIndex();
93
+ const index = readIndex(onError);
90
94
  const out = [];
91
95
  const walk = (dir, depth) => {
92
96
  let entries;
93
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
97
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
98
+ catch (error) { onError(dir, error); return; }
94
99
  for (const e of entries) {
95
100
  const p = path.join(dir, e.name);
96
101
  if (e.isDirectory() && depth < 3) walk(p, depth + 1);
@@ -98,7 +103,7 @@ export function listSessions() {
98
103
  try {
99
104
  const s = readSession(p, index);
100
105
  if (s && !s.subagent) out.push(s);
101
- } catch { /* unreadable file */ }
106
+ } catch (error) { onError(p, error); }
102
107
  }
103
108
  }
104
109
  };
package/src/sessions.mjs CHANGED
@@ -5,11 +5,17 @@ import * as codex from './providers/codex.mjs';
5
5
  export const PROVIDERS = { claude, codex };
6
6
  export const AGENTS = Object.keys(PROVIDERS);
7
7
 
8
- // Both agents' sessions in one list, newest use first.
9
- export function listSessions({ agents = AGENTS } = {}) {
8
+ const reportError = (agent, file, error) =>
9
+ console.error(`ccnav: could not read ${agent} sessions${file ? ` at ${file}` : ''}: ${error.message}`);
10
+
11
+ // Both agents' sessions in one list, newest use first. Report unreadable sources on stderr.
12
+ export function listSessions({ agents = AGENTS, onError = reportError } = {}) {
10
13
  const out = [];
11
14
  for (const name of agents) {
12
- try { out.push(...PROVIDERS[name].listSessions()); } catch { /* provider not installed */ }
15
+ const provider = PROVIDERS[name];
16
+ if (!provider) { onError(name, null, new Error('unknown agent')); continue; }
17
+ try { out.push(...provider.listSessions({ onError: (file, error) => onError(name, file, error) })); }
18
+ catch (error) { onError(name, null, error); }
13
19
  }
14
20
  return out.sort((a, b) => b.lastUsed - a.lastUsed);
15
21
  }
@@ -0,0 +1,11 @@
1
+ // Started in a new Windows Terminal tab. The payload is data, so neither shell
2
+ // interprets session IDs or forwarded arguments as command source.
3
+ import { launchAgent } from './platform.mjs';
4
+
5
+ try {
6
+ const { cmd, cwd, args } = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
7
+ process.exit(await launchAgent(cmd, cwd, args));
8
+ } catch (error) {
9
+ console.error(`ccnav: could not start agent in new tab: ${error.message}`);
10
+ process.exit(127);
11
+ }