ccnav 1.0.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
@@ -6,15 +6,24 @@ Works on Windows, macOS and Linux with Node 20 or later. Everything that differs
6
6
 
7
7
  ## Install
8
8
 
9
- From a checkout (any OS):
9
+ ```sh
10
+ npm install -g ccnav # the -g matters
11
+ ccnav --install-shell # optional: leave your shell in the session's directory afterwards
12
+ ```
13
+
14
+ Without `-g`, npm treats the current folder as a project and puts the command in
15
+ `./node_modules/.bin`, which is not on PATH — the install succeeds and `ccnav` still
16
+ reports `command not found`. If that happens after a `-g` install, npm's global bin
17
+ folder is missing from PATH; `npm prefix -g` prints the folder whose `bin` belongs there.
18
+
19
+ From a checkout instead:
10
20
 
11
21
  ```sh
12
22
  npm install
13
23
  npm link # puts `ccnav` on PATH
14
- ccnav --install-shell # optional: leave your shell in the session's directory afterwards
15
24
  ```
16
25
 
17
- Or build a tarball once and install it anywhere, e.g. on a Mac:
26
+ Or build a tarball once and install it anywhere:
18
27
 
19
28
  ```sh
20
29
  npm pack # -> ccnav-1.0.0.tgz
@@ -23,7 +32,23 @@ npm install -g ./ccnav-1.0.0.tgz
23
32
 
24
33
  `--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
34
 
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.
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
+
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
+
39
+ ## Agents
40
+
41
+ Sessions are listed whether or not the agent that wrote them is still installed, so an
42
+ old machine's transcripts stay visible. Resuming one whose agent is missing offers to
43
+ install it:
44
+
45
+ ```
46
+ codex is not installed. Install it now with "npm install -g @openai/codex"? (y/N)
47
+ ```
48
+
49
+ Answer `y` and ccnav runs that install, then launches the agent so you can sign in and
50
+ finish its own setup. Answer `n` and nothing is installed. `claude` maps to
51
+ `@anthropic-ai/claude-code` and `codex` to `@openai/codex`.
27
52
 
28
53
  ## Use
29
54
 
@@ -34,7 +59,38 @@ ccnav --codex only Codex sessions
34
59
  ccnav --list print sessions and exit
35
60
  ccnav --json print sessions as JSON and exit
36
61
  ccnav --model opus any other arguments are passed to the agent
62
+ ccnav here new session in the current directory, running unattended
63
+ ccnav here --codex the same, without being asked which agent
64
+ ```
65
+
66
+ ## Start one here
67
+
68
+ `ccnav here` ignores the session list entirely. It asks which agent, then starts a
69
+ **new** session in the current directory with that agent's permission prompts turned
70
+ off, so it runs unattended:
71
+
37
72
  ```
73
+ New session in /Users/you/project
74
+
75
+ 1) ◆ claude claude --dangerously-skip-permissions
76
+ 2) ● codex codex --dangerously-bypass-approvals-and-sandbox
77
+
78
+ Which agent? [1]
79
+ ```
80
+
81
+ Pressing enter takes the first agent; `1`/`2` or a name (`codex`, `cl`) picks one.
82
+ `ccnav here --claude` or `--codex` skips the question, and any other arguments go to
83
+ the agent (`ccnav here --claude --model opus`). The exact command is printed before it
84
+ runs, and ctrl-D at the prompt starts nothing.
85
+
86
+ What those flags mean is worth being clear about. Claude's
87
+ `--dangerously-skip-permissions` bypasses every permission check. Codex's
88
+ `--dangerously-bypass-approvals-and-sandbox` skips approvals *and* runs without a
89
+ sandbox — its own help calls it EXTREMELY DANGEROUS and intends it for environments
90
+ that are already sandboxed. Neither confines the agent to the directory you started in.
91
+ Codex removed `--full-auto` in 0.156, so the sandboxed middle ground is now spelled
92
+ `--ask-for-approval never --sandbox workspace-write` if you would rather edit
93
+ `src/providers/codex.mjs` to use that instead.
38
94
 
39
95
  | Key | Action |
40
96
  |---|---|
@@ -66,7 +122,7 @@ ccnav --model opus any other arguments are passed to the agent
66
122
  | Resume / fork | `claude --resume <id>` / `--fork-session` | `codex resume <id>` / `codex fork <id>` |
67
123
  | Hidden | subagent sidechain folders, Desktop scratch workspaces | subagent threads spawned by other sessions |
68
124
 
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.
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.
70
126
 
71
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.
72
128
 
package/bin/ccnav.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  import React from 'react';
3
3
  import { render } from 'ink';
4
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';
5
+ import { AGENTS, PROVIDERS, ago, humanSize, listSessions, providerOf, shortPath } from '../src/sessions.mjs';
6
+ import { askUser, ensureAgent, installShellFunction, launchAgent, reportCwd } from '../src/actions.mjs';
7
7
 
8
8
  const args = process.argv.slice(2);
9
9
 
@@ -17,6 +17,9 @@ usage:
17
17
  ccnav --codex only Codex sessions
18
18
  ccnav --list print sessions as a table and exit
19
19
  ccnav --json print sessions as JSON and exit
20
+ ccnav here skip the picker: start a NEW session in the current
21
+ directory, running unattended with no permission prompts
22
+ (ccnav here --claude / --codex picks the agent outright)
20
23
  ccnav --install-shell add a shell function (PowerShell, zsh, bash or fish) so
21
24
  your shell is left in the session's directory afterwards
22
25
 
@@ -24,6 +27,40 @@ Any other arguments are passed to the agent (e.g. ccnav --model opus).`);
24
27
  process.exit(0);
25
28
  }
26
29
 
30
+ // `ccnav here` never touches the session list: it starts a new one in this directory.
31
+ async function chooseAgent() {
32
+ if (!process.stdin.isTTY) {
33
+ console.error('ccnav here needs an interactive terminal, or name the agent: ccnav here --claude');
34
+ process.exit(1);
35
+ }
36
+ console.log(`\nNew session in ${process.cwd()}\n`);
37
+ AGENTS.forEach((a, i) => {
38
+ const p = PROVIDERS[a];
39
+ console.log(` ${i + 1}) ${p.glyph} ${a.padEnd(7)} ${p.command} ${p.AUTO_ARGS.join(' ')}`);
40
+ });
41
+ const answer = await askUser(`\nWhich agent? [1] `);
42
+ // EOF is not a choice: never start an unattended agent nobody actually asked for.
43
+ if (answer === null) { console.error('\nNo answer given, nothing started.'); process.exit(1); }
44
+ if (!answer) return AGENTS[0];
45
+ const byNumber = AGENTS[Number(answer) - 1];
46
+ if (byNumber) return byNumber;
47
+ const byName = AGENTS.find(a => a.startsWith(answer.toLowerCase()));
48
+ if (byName) return byName;
49
+ console.error(`Not an agent: ${answer}`);
50
+ process.exit(1);
51
+ }
52
+
53
+ if (args[0] === 'here') {
54
+ const rest = args.slice(1);
55
+ const named = AGENTS.find(a => rest.includes(`--${a}`));
56
+ const extra = rest.filter(a => a !== '--' && !AGENTS.some(x => a === `--${x}`));
57
+ const provider = PROVIDERS[named || await chooseAgent()];
58
+ if (!await ensureAgent(provider.command)) process.exit(127);
59
+ const argv = [...provider.AUTO_ARGS, ...extra];
60
+ console.log(`\n\u2192 ${process.cwd()}\n\u2192 ${provider.command} ${argv.join(' ')}\n`);
61
+ process.exit(await launchAgent(provider.command, process.cwd(), argv));
62
+ }
63
+
27
64
  if (args.includes('--install-shell')) {
28
65
  const { shell, file, reload } = installShellFunction();
29
66
  console.log(`ccnav ${shell} function written to ${file}\nto use it now: ${reload}`);
@@ -64,6 +101,9 @@ const { action, session } = choice;
64
101
  const provider = providerOf(session);
65
102
  const agentArgs = provider.launchArgs(action, session, passthrough);
66
103
 
104
+ // The agent may not be installed yet; offer to install it rather than just failing.
105
+ if (!await ensureAgent(provider.command)) process.exit(127);
106
+
67
107
  reportCwd(session.cwd);
68
108
  console.log(`\n→ ${session.cwd}\n→ ${provider.command} ${agentArgs.join(' ')}\n`);
69
109
  process.exit(await launchAgent(provider.command, session.cwd, agentArgs));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccnav",
3
- "version": "1.0.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
- export { copyToClipboard, installShellFunction, launchAgent, openFolder, openInNewTab } from './platform.mjs';
3
+ import readline from 'node:readline';
4
+ import { setImmediate as yieldToUi } from 'node:timers/promises';
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
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react';
2
2
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
3
  import { AGENTS, ago, humanSize, providerOf } from './sessions.mjs';
4
- import { archiveSession, copyToClipboard, isMaybeLive, openFolder, openInNewTab, searchTranscript } from './actions.mjs';
4
+ import { archiveSession, copyToClipboard, hasCommand, isMaybeLive, openFolder, openInNewTab, searchTranscript } from './actions.mjs';
5
5
 
6
6
  const h = React.createElement;
7
7
 
@@ -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
 
@@ -212,6 +218,11 @@ export function App({ sessions: initialSessions, onChoose, passthrough = [] }) {
212
218
  else if (input === 't' && sel && requireDir()) {
213
219
  try {
214
220
  const p = providerOf(sel);
221
+ // A new tab has nowhere to report an install prompt, so send them to enter instead.
222
+ if (!hasCommand(p.command)) {
223
+ flash(`${p.command} is not installed — press enter to resume and ccnav will offer to install it.`, 'yellow');
224
+ return;
225
+ }
215
226
  const where = openInNewTab(p.command, sel.cwd, p.launchArgs('resume', sel, passthrough), oneLine(sel.title) || sel.id);
216
227
  flash(`Opened "${oneLine(sel.title) || sel.id}" in a new ${where}`);
217
228
  } catch (e) { flash(`Could not open a new tab: ${e.message}`, 'red'); }
@@ -229,7 +240,7 @@ export function App({ sessions: initialSessions, onChoose, passthrough = [] }) {
229
240
  else if (input === 's') { setSortIdx(i => (i + 1) % SORTS.length); setCursor(0); }
230
241
  else if (key.tab) { setAgentIdx(i => (i + 1) % (AGENTS.length + 1)); setCursor(0); }
231
242
  else if (input === 'a') { setShowScratch(v => !v); setCursor(0); }
232
- else if (key.escape && filter) { setFilter(''); setDeepHits(null); setCursor(0); }
243
+ else if (key.escape && filter) { setFilter(''); setDeepHits(null); setSearchQuery(null); setCursor(0); }
233
244
  else if (input === 'q' || key.escape) exit();
234
245
  });
235
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
@@ -2,7 +2,10 @@
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import readline from 'node:readline';
5
6
  import { spawn, spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import crossSpawn from 'cross-spawn';
6
9
 
7
10
  export const IS_WIN = process.platform === 'win32';
8
11
  export const IS_MAC = process.platform === 'darwin';
@@ -11,46 +14,156 @@ const has = cmd => IS_WIN
11
14
  ? spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0
12
15
  : spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' }).status === 0;
13
16
 
17
+ // Exported so the picker can warn before opening a tab that would only fail.
18
+ export const hasCommand = has;
19
+
14
20
  // POSIX single-quote escaping for building `sh -c` / AppleScript command lines.
15
21
  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
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
+ }
19
45
 
20
- const INSTALL_HINT = {
21
- claude: 'npm install -g @anthropic-ai/claude-code',
22
- codex: 'npm install -g @openai/codex',
46
+ export const INSTALL_PKG = {
47
+ claude: '@anthropic-ai/claude-code',
48
+ codex: '@openai/codex',
23
49
  };
50
+ const hint = cmd => INSTALL_PKG[cmd] ? ` Install it: npm install -g ${INSTALL_PKG[cmd]}` : '';
24
51
 
25
52
  // Runs the agent in the foreground with the session's directory as cwd; resolves with its exit code.
26
53
  export function launchAgent(cmd, cwd, args) {
27
54
  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 => {
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 => {
33
64
  console.error(e.code === 'ENOENT'
34
- ? `${cmd} was not found on PATH.${INSTALL_HINT[cmd] ? ` Install it: ${INSTALL_HINT[cmd]}` : ''}`
65
+ ? `${shell} was not found on PATH.${shell === cmd ? hint(cmd) : ''}`
35
66
  : `could not start ${cmd}: ${e.message}`);
36
67
  resolve(127);
37
- });
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);
38
73
  child.on('exit', code => resolve(code ?? 0));
39
74
  });
40
75
  }
41
76
 
77
+ // ---- installing a missing agent ----
78
+
79
+ // Resolves the trimmed answer, or null when stdin closed without one (ctrl-D, a closed pipe).
80
+ // Resolving on 'close' rather than only in the callback keeps the promise from hanging on EOF.
81
+ export function askUser(question) {
82
+ return new Promise(resolve => {
83
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
84
+ let answer = null;
85
+ rl.question(question, a => { answer = String(a).trim().toLowerCase(); rl.close(); });
86
+ rl.on('close', () => resolve(answer));
87
+ });
88
+ }
89
+
90
+ // Offers to install the agent when it is missing. Resolves true if it is on PATH afterwards.
91
+ // The agent's own first-run login/setup then runs as usual when it launches.
92
+ export async function ensureAgent(cmd) {
93
+ if (has(cmd)) return true;
94
+
95
+ const pkg = INSTALL_PKG[cmd];
96
+ if (!pkg) {
97
+ console.error(`${cmd} was not found on PATH.`);
98
+ return false;
99
+ }
100
+ // Without a terminal there is nobody to answer the prompt (e.g. piped output).
101
+ if (!process.stdin.isTTY) {
102
+ console.error(`${cmd} is not installed.${hint(cmd)}`);
103
+ return false;
104
+ }
105
+ if (!has('npm')) {
106
+ console.error(`${cmd} is not installed, and npm is not available to install it.`);
107
+ console.error(`Install Node.js 20 or later, then run: npm install -g ${pkg}`);
108
+ return false;
109
+ }
110
+
111
+ const answer = await askUser(`\n${cmd} is not installed. Install it now with "npm install -g ${pkg}"? (y/N) `);
112
+ if (answer !== 'y' && answer !== 'yes') {
113
+ console.log(`Skipped. You can install it later: npm install -g ${pkg}`);
114
+ return false;
115
+ }
116
+
117
+ console.log(`\nnpm install -g ${pkg}\n`);
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' });
122
+ if (r.error || r.status !== 0) {
123
+ console.error(`\nInstall failed${r.error ? `: ${r.error.message}` : ` (npm exited with ${r.status})`}.`);
124
+ console.error(`If it was a permissions error, npm's global folder is not writable by you.`);
125
+ console.error(`Check where it points with: npm prefix -g`);
126
+ return false;
127
+ }
128
+ if (!has(cmd)) {
129
+ console.error(`\n${pkg} installed, but "${cmd}" is still not on PATH.`);
130
+ console.error(`npm's global bin folder is probably not in PATH. Find it with: npm prefix -g`);
131
+ return false;
132
+ }
133
+ console.log(`\n${cmd} installed.\n`);
134
+ return true;
135
+ }
136
+
137
+ // ---- new terminal tabs ----
138
+
139
+ const run = (cmd, argv) => {
140
+ const r = spawnSync(cmd, argv, { encoding: 'utf8' });
141
+ if (r.error) throw new Error(r.error.message);
142
+ if (r.status !== 0) {
143
+ // Keep the tool's own message: AppleScript errors are the only clue to a denied permission.
144
+ const detail = String(r.stderr || '').trim().split('\n').find(Boolean);
145
+ throw new Error(detail || `${cmd} exited with ${r.status}`);
146
+ }
147
+ };
148
+
149
+ // macOS asks for Automation permission the first time; a denial comes back as -1743.
150
+ function macAutomationError(e, app) {
151
+ const msg = e?.message || String(e);
152
+ return /-1743|not authoriz/i.test(msg)
153
+ ? `macOS blocked ccnav from controlling ${app}. Allow your terminal under System Settings → Privacy & Security → Automation, then try again.`
154
+ : msg;
155
+ }
156
+
42
157
  // Opens a new terminal tab (or window) in `cwd` running `cmd` with `args`. Throws if nothing worked.
43
158
  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
159
  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 });
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 });
54
167
  if (r.error || r.status !== 0) throw new Error(r.error?.message || `wt.exe exited with ${r.status}`);
55
168
  return 'Windows Terminal tab';
56
169
  }
@@ -68,15 +181,27 @@ export function openInNewTab(cmd, cwd, args, title = cmd) {
68
181
  }
69
182
  if (IS_MAC) {
70
183
  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
- ]);
184
+ try {
185
+ // A tab needs a window to live in: iTerm can be running with none open.
186
+ run('osascript', ['-e', [
187
+ 'tell application "iTerm"',
188
+ ' activate',
189
+ ' if (count of windows) = 0 then',
190
+ ' create window with default profile',
191
+ ' else',
192
+ ' tell current window to create tab with default profile',
193
+ ' end if',
194
+ ` tell current session of current window to write text ${osaString(shellCmd)}`,
195
+ 'end tell',
196
+ ].join('\n')]);
197
+ } catch (e) { throw new Error(macAutomationError(e, 'iTerm')); }
75
198
  return 'iTerm tab';
76
199
  }
77
200
  // 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']);
201
+ try {
202
+ run('osascript', ['-e', `tell application "Terminal" to do script ${osaString(shellCmd)}`,
203
+ '-e', 'tell application "Terminal" to activate']);
204
+ } catch (e) { throw new Error(macAutomationError(e, 'Terminal')); }
80
205
  return 'Terminal window';
81
206
  }
82
207
  if (has('gnome-terminal')) {
@@ -8,6 +8,8 @@ export const agent = 'claude';
8
8
  export const command = 'claude';
9
9
  export const glyph = '◆';
10
10
  export const color = 'cyan';
11
+ // `ccnav here`: a fresh session that never stops to ask for permission.
12
+ export const AUTO_ARGS = ['--dangerously-skip-permissions'];
11
13
 
12
14
  export const HOME_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
13
15
  export const SESSIONS_ROOT = path.join(HOME_DIR, 'projects');
@@ -66,18 +68,22 @@ export function readSession(file) {
66
68
  }
67
69
 
68
70
  // Top-level *.jsonl only: <sessionId>/ subdirectories hold subagent sidechains.
69
- export function listSessions() {
71
+ export function listSessions({ onError = (file, error) => console.error(`ccnav: could not read Claude session at ${file}: ${error.message}`) } = {}) {
70
72
  if (!fs.existsSync(SESSIONS_ROOT)) return [];
71
73
  const out = [];
72
74
  for (const dir of fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true })) {
73
75
  if (!dir.isDirectory()) continue;
74
76
  const projectDir = path.join(SESSIONS_ROOT, dir.name);
75
- 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) {
76
81
  if (!f.endsWith('.jsonl')) continue;
82
+ const file = path.join(projectDir, f);
77
83
  try {
78
- const s = readSession(path.join(projectDir, f));
84
+ const s = readSession(file);
79
85
  if (s) out.push(s);
80
- } catch { /* unreadable file */ }
86
+ } catch (error) { onError(file, error); }
81
87
  }
82
88
  }
83
89
  return out;
@@ -8,6 +8,9 @@ export const agent = 'codex';
8
8
  export const command = 'codex';
9
9
  export const glyph = '●';
10
10
  export const color = 'green';
11
+ // `ccnav here`: codex removed --full-auto in 0.156, so the policy is named directly.
12
+ // This is the counterpart to Claude's --dangerously-skip-permissions: no approvals, no sandbox.
13
+ export const AUTO_ARGS = ['--dangerously-bypass-approvals-and-sandbox'];
11
14
 
12
15
  export const HOME_DIR = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
13
16
  export const SESSIONS_ROOT = path.join(HOME_DIR, 'sessions');
@@ -15,10 +18,14 @@ export const ARCHIVE_ROOT = path.join(HOME_DIR, 'ccnav-archive');
15
18
  const INDEX_FILE = path.join(HOME_DIR, 'session_index.jsonl');
16
19
 
17
20
  // session_index.jsonl names about half the sessions; read it once per listing.
18
- function readIndex() {
21
+ function readIndex(onError = () => {}) {
19
22
  const map = new Map();
20
23
  let text;
21
- 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
+ }
22
29
  for (const line of text.split('\n')) {
23
30
  if (!line.trim()) continue;
24
31
  try {
@@ -81,13 +88,14 @@ export function readSession(file, index = readIndex()) {
81
88
  }
82
89
 
83
90
  // sessions/YYYY/MM/DD/*.jsonl
84
- export function listSessions() {
91
+ export function listSessions({ onError = (file, error) => console.error(`ccnav: could not read Codex session at ${file}: ${error.message}`) } = {}) {
85
92
  if (!fs.existsSync(SESSIONS_ROOT)) return [];
86
- const index = readIndex();
93
+ const index = readIndex(onError);
87
94
  const out = [];
88
95
  const walk = (dir, depth) => {
89
96
  let entries;
90
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
97
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
98
+ catch (error) { onError(dir, error); return; }
91
99
  for (const e of entries) {
92
100
  const p = path.join(dir, e.name);
93
101
  if (e.isDirectory() && depth < 3) walk(p, depth + 1);
@@ -95,7 +103,7 @@ export function listSessions() {
95
103
  try {
96
104
  const s = readSession(p, index);
97
105
  if (s && !s.subagent) out.push(s);
98
- } catch { /* unreadable file */ }
106
+ } catch (error) { onError(p, error); }
99
107
  }
100
108
  }
101
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
+ }