claude-term-tabs 0.0.0-bootstrap

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 Emma Coutu
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,170 @@
1
+ # claude-term-tabs
2
+
3
+ Find a running Claude Code session by what you remember about it, and switch to its terminal tab.
4
+
5
+ ```bash
6
+ ctt working on pause in claude-term-tabs
7
+ # → Pause sessions before a restart · claude-term-tabs · tab 4
8
+ ```
9
+
10
+ Works with iTerm2 and WezTerm on macOS.
11
+
12
+ ## Install
13
+
14
+ Needs Node 24 or later. `fzf` is optional; without it the picker is a numbered list.
15
+
16
+ ```bash
17
+ npm install --global claude-term-tabs # installs both claude-term-tabs and ctt
18
+ npx claude-term-tabs pause # or run it without installing
19
+ ```
20
+
21
+ To run it from a clone instead, link the clone with `npm link`.
22
+
23
+ The first switch in iTerm2 asks macOS for permission to control iTerm2. Allow it, or grant it
24
+ later in System Settings > Privacy & Security > Automation.
25
+
26
+ ### Tab completion
27
+
28
+ `ctt completion zsh` or `ctt completion bash` prints a completion script (plain `ctt completion` picks
29
+ the shell from `$SHELL`). Append it to your shell's startup file:
30
+
31
+ ```bash
32
+ ctt completion zsh >> ~/.zshrc # after compinit runs
33
+ ctt completion bash >> ~/.bashrc # bash 4 or later
34
+ ```
35
+
36
+ Tab then completes ctt's subcommands (`pause`, `resume`, `completion`) as the first word, each one's
37
+ flags, and the terminal names after `--terminal`; after `--snapshot` it falls back to file names. It
38
+ never completes session titles. The script completes both `ctt` and `claude-term-tabs`. Installed with npm, it runs
39
+ `claude-term-tabs` from your `PATH`, so completion needs a global install (`npx` alone isn't enough). Run from a
40
+ clone, it runs ctt from where it was when you generated the script, so generate it again if you move the clone.
41
+
42
+ ## Usage
43
+
44
+ ```
45
+ ctt [query...] [--list] [--all] [--json] [--no-llm] [--terminal iterm2|wezterm]
46
+ ```
47
+
48
+ | Situation | What happens |
49
+ |-----------|--------------|
50
+ | One clear local match | Switches to its tab |
51
+ | Several plausible matches | Asks Haiku to rank them, then shows a picker (or switches if Haiku picks exactly one) |
52
+ | No query | Picker over every live session, most recent first |
53
+ | `--list` | Prints matches, never switches |
54
+ | `--all` | Also searches closed sessions; choosing one resumes it in a new tab |
55
+ | `--json` | Machine-readable output, for debugging; no format promise |
56
+ | `--no-llm` | Local matching only |
57
+ | Not in iTerm2 or WezTerm | Uses whichever one is running, else lists without switching |
58
+
59
+ The search looks at each session's title, repo, worktree, branch and your first and last prompt.
60
+
61
+ ## Pause and resume
62
+
63
+ Before restarting the machine:
64
+
65
+ ```bash
66
+ ctt pause # ask every session to wrap up and write a HANDOFF
67
+ ctt pause --auto-close # also exit each session and close its tab once its HANDOFF is in
68
+ ctt pause --timeout 20 # wait up to 20 minutes (default 10)
69
+ ```
70
+
71
+ `ctt pause` records every live session to a snapshot first, then types the stop prompt into each
72
+ one and shows the sessions it is still waiting on, refreshed every 3 seconds. Enter or a
73
+ double-click jumps to that session's tab; ctrl-r re-sends the stop prompt; Esc stops waiting.
74
+ When the list empties it prints `Safe to restart.`
75
+
76
+ | State | Meaning |
77
+ |-------|---------|
78
+ | busy | Working; its transcript is still growing |
79
+ | may need you | Busy for 2 minutes with no new output, probably a permission prompt |
80
+ | idle, no HANDOFF | Stopped without writing a HANDOFF; re-send or check it |
81
+ | in shell mode | Its input is in shell mode, so ctt will not type into it |
82
+ | can't reach | Not in a supported terminal pane |
83
+ | exited | The process ended without a HANDOFF |
84
+ | exit failed | `--auto-close` sent `/exit` but the session did not end |
85
+
86
+ ctt only ever types the stop prompt and, with `--auto-close`, `/exit` followed by `exit` for the
87
+ shell left behind. It never answers a permission prompt or interrupts a busy session, and it never
88
+ closes a session that has not written a HANDOFF. Sessions without one are still recorded.
89
+
90
+ After restarting:
91
+
92
+ ```bash
93
+ ctt resume # reopen every recorded session in its window and tab order
94
+ ctt resume --dry-run # print the commands instead
95
+ ctt resume --no-prompt # reopen without sending the resume prompt
96
+ ```
97
+
98
+ Sessions already running are skipped, so running it twice is harmless. Snapshots live in
99
+ `~/.local/state/claude-term-tabs/snapshots/` and are pruned after 30 days. `ctt resume` uses the newest;
100
+ `--snapshot FILE` picks another.
101
+
102
+ ## Config
103
+
104
+ `~/.config/claude-term-tabs/config.json`, every key optional:
105
+
106
+ ```json
107
+ {
108
+ "resumeCommand": "claude --dangerously-skip-permissions --resume {id}",
109
+ "stopPrompt": "Pausing for a machine restart. ...",
110
+ "resumePrompt": "Resumed after a machine restart. ...",
111
+ "pauseTimeoutMinutes": 10,
112
+ "excludeDirs": ["~/.claude-mem/observer-sessions"]
113
+ }
114
+ ```
115
+
116
+ `excludeDirs` leaves closed sessions out of `--all` when their working directory (the one ctt
117
+ shows and resumes in) is one of those directories or below one. Live sessions are always listed.
118
+
119
+ ## Cost and speed
120
+
121
+ Measured on 2026-11-10 with 19 live sessions:
122
+
123
+ | Path | Time | Tokens |
124
+ |------|------|--------|
125
+ | Local match | ~0.65 s | none |
126
+ | Haiku fallback (15 sessions sent) | ~4 s | ~2,300 input, ~250 output (about $0.0006) |
127
+ | `--all` first run (1,626 transcripts indexed) | ~4 s plus any Haiku call | none |
128
+ | `--all` cached | adds ~40 ms | none |
129
+
130
+ Haiku runs through `claude -p` with no tools, no MCP servers, no settings, a short system prompt,
131
+ and `--no-session-persistence`, so its calls never show up as sessions.
132
+
133
+ Digests are cached in `~/.cache/claude-term-tabs/digests.json` (owner-only), keyed by transcript size
134
+ and modification time. Delete it any time.
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ npm ci # installs the commit hooks (husky + commitlint)
140
+ npm run check # type-check
141
+ npm test
142
+ ```
143
+
144
+ No runtime dependencies; Node runs the TypeScript directly. [CONTRIBUTING.md](CONTRIBUTING.md) has the commit and
145
+ pull request rules.
146
+
147
+ ### Releases
148
+
149
+ Every push to `main` runs CI, then semantic-release reads the conventional commit
150
+ messages since the last tag and, when any warrant it, tags the next version (`fix:` patch,
151
+ `feat:` minor, `BREAKING CHANGE:` major), adds the notes to [CHANGELOG.md](CHANGELOG.md) and
152
+ publishes a GitHub release. Nothing is published to npm, and `package.json` keeps a placeholder
153
+ version; the git tag is the version.
154
+
155
+ ### Manual checks
156
+
157
+ iTerm2:
158
+
159
+ 1. `ctt --list <words from a tab title>` prints that tab with the right `tab N`.
160
+ 2. `ctt <words from a tab title>` switches to it.
161
+ 3. `ctt claude-term-tabs` (or any phrase matching two sessions) opens the fzf picker; Enter switches.
162
+ 4. `ctt --all <words from a closed session>` resumes it in a new tab.
163
+
164
+ WezTerm: the same four checks, run inside WezTerm.
165
+
166
+ Pause and resume:
167
+
168
+ 1. `ctt resume --dry-run` after a `ctt pause` lists every session in tab order.
169
+ 2. `ctt pause` with two scratch sessions shows them leaving the list as they write HANDOFFs.
170
+ 3. One full restart cycle: `ctt pause --auto-close`, restart, `ctt resume`.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, realpathSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ const root = dirname(dirname(realpathSync(process.argv[1])))
7
+ const argv = process.argv.slice(2)
8
+ // A checkout runs the TypeScript in src/; the npm package ships only dist/, because Node won't strip types under node_modules.
9
+ const fromSrc = existsSync(join(root, 'src')) && !root.split('/').includes('node_modules')
10
+ const src = file => pathToFileURL(fromSrc ? join(root, 'src', file) : join(root, 'dist', file.replace(/\.ts$/, '.js'))).href
11
+ const { COMPLETE_FLAG, answerCompletion } = await import(src('complete.ts'))
12
+ if (argv[0] === COMPLETE_FLAG) {
13
+ // A Tab press: answer without loading the rest of ctt.
14
+ const { lineWriter } = await import(src('output.ts'))
15
+ process.exitCode = answerCompletion(argv.slice(1), { stdout: lineWriter(1), stderr: lineWriter(2) })
16
+ } else {
17
+ const { main } = await import(src('cli.ts'))
18
+ process.exitCode = await main(argv)
19
+ }
package/dist/args.js ADDED
@@ -0,0 +1,171 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { shellQuote } from "./shell.js";
3
+ import { TERMINAL_NAMES } from "./terminals/adapter.js";
4
+ const HELP_FLAG = { short: 'h', describe: 'show this help' };
5
+ /** Flags of the search, `ctt [query...]`. */
6
+ export const SEARCH_FLAGS = {
7
+ list: { describe: 'print matches, never switch' },
8
+ all: { describe: 'include closed sessions (choosing one resumes it in a new tab)' },
9
+ json: { describe: 'machine-readable output (implies --list)' },
10
+ 'no-llm': { describe: 'local matching only, never call Haiku' },
11
+ terminal: { value: 'NAME', describe: `${TERMINAL_NAMES.join(' | ')} (default: detected)`, choices: TERMINAL_NAMES },
12
+ help: HELP_FLAG
13
+ };
14
+ /** Flags of `ctt pause`. */
15
+ export const PAUSE_FLAGS = {
16
+ 'auto-close': { describe: 'also exit each session and close its tab after its HANDOFF' },
17
+ timeout: { value: 'MIN', describe: 'minutes to wait (default: pauseTimeoutMinutes, else 10)' },
18
+ help: HELP_FLAG
19
+ };
20
+ /** Flags of `ctt resume`. */
21
+ export const RESUME_FLAGS = {
22
+ 'dry-run': { describe: 'print the commands instead' },
23
+ 'no-prompt': { describe: 'reopen without sending the resume prompt' },
24
+ snapshot: { value: 'FILE', describe: 'use this snapshot instead of the latest' },
25
+ help: HELP_FLAG
26
+ };
27
+ /** Shells `ctt completion` writes a script for. */
28
+ export const SHELLS = ['bash', 'zsh'];
29
+ export function isShell(s) {
30
+ return SHELLS.some(x => x === s);
31
+ }
32
+ /** The public subcommands. The internal `_focus` and `_send-stop` are deliberately not here. */
33
+ export const COMMANDS = {
34
+ pause: { describe: 'before a restart, record live sessions and ask each for a HANDOFF', flags: PAUSE_FLAGS },
35
+ resume: { describe: 'after a restart, reopen recorded sessions in window and tab order', flags: RESUME_FLAGS },
36
+ completion: { describe: `print the tab-completion script for ${SHELLS.join(' or ')} (default: from $SHELL)`, flags: { help: HELP_FLAG }, args: `[${SHELLS.join('|')}]` }
37
+ };
38
+ export function isCommand(word) {
39
+ return word !== undefined && Object.hasOwn(COMMANDS, word);
40
+ }
41
+ /** The hidden first argument the completion scripts call ctt with: `ctt --complete <shell> <words>`. */
42
+ export const COMPLETE_FLAG = '--complete';
43
+ /**
44
+ * Parse `argv` against `flags` with `node:util` `parseArgs` in strict mode: unknown flags, a value
45
+ * given to a boolean flag and a missing value throw, and so do positionals unless `positionals` is set.
46
+ */
47
+ export function parseFlags(argv, flags, opts = {}) {
48
+ const options = Object.fromEntries(Object.entries(flags).map(([name, f]) => [name, {
49
+ ...(f.value !== undefined ? { type: 'string' } : { type: 'boolean', default: false }),
50
+ ...(f.short !== undefined ? { short: f.short } : {})
51
+ }]));
52
+ const { values, positionals } = parseArgs({ args: argv, options, strict: true, allowPositionals: opts.positionals === true });
53
+ return { values: values, positionals };
54
+ }
55
+ /** The flag whose value is being completed, if any: `--terminal x`, bash's `--terminal = x`, or zsh's `--terminal=x`. */
56
+ function valueBeingCompleted(before, current) {
57
+ const joined = /^--([^=]+)=(.*)$/.exec(current);
58
+ if (joined !== null)
59
+ return { flag: joined[1], value: joined[2], prefix: `--${joined[1]}=` };
60
+ const [flag, value] = current === '=' ? [before.at(-1), ''] : before.at(-1) === '=' ? [before.at(-2), current] : [before.at(-1), current];
61
+ return flag?.startsWith('--') === true ? { flag: flag.slice(2), value, prefix: '' } : undefined;
62
+ }
63
+ /** `name` for bash; for zsh's `_describe`, `name` with its colons escaped, then `:description` if any. */
64
+ export function entry(shell, name, describe) {
65
+ if (shell === 'bash')
66
+ return name;
67
+ const escaped = name.replaceAll(':', '\\:');
68
+ return describe === undefined ? escaped : `${escaped}:${describe}`;
69
+ }
70
+ /** Names of the flags already on the line, however given: `--name`, `--name=value`, or a short `-h`. */
71
+ function given(before, flags) {
72
+ const names = new Set();
73
+ for (const word of before) {
74
+ if (word.startsWith('--'))
75
+ names.add(word.slice(2).split('=')[0]);
76
+ else if (/^-[^-]/.test(word)) {
77
+ for (const short of word.slice(1)) {
78
+ const name = Object.entries(flags).find(([, f]) => f.short === short)?.[0];
79
+ if (name !== undefined)
80
+ names.add(name);
81
+ }
82
+ }
83
+ }
84
+ return names;
85
+ }
86
+ /**
87
+ * Answer the completion script's `ctt --complete <shell> <words>`, where `words` is the command line up
88
+ * to the word being completed (the program name first). Subcommands are offered only as the first word;
89
+ * flags (long form) when the word starts with `-`, minus those already given; a flag that takes a value
90
+ * offers its choices or nothing, so the shell falls back to file names; nothing after `--`.
91
+ */
92
+ export function completions(shell, words) {
93
+ const typed = words.slice(1);
94
+ const current = typed.at(-1) ?? '';
95
+ const before = typed.slice(0, -1);
96
+ if (before.includes('--'))
97
+ return [];
98
+ const command = isCommand(before[0]) ? before[0] : undefined;
99
+ const flags = command !== undefined ? COMMANDS[command].flags : SEARCH_FLAGS;
100
+ const value = valueBeingCompleted(before, current);
101
+ const flag = value !== undefined && Object.hasOwn(flags, value.flag) ? flags[value.flag] : undefined;
102
+ if (value !== undefined && flag?.value !== undefined) {
103
+ return (flag.choices ?? []).filter(c => c.startsWith(value.value)).map(c => entry(shell, value.prefix + c));
104
+ }
105
+ if (current.startsWith('-')) {
106
+ const already = given(before, flags);
107
+ return Object.entries(flags)
108
+ .filter(([name]) => `--${name}`.startsWith(current) && !already.has(name))
109
+ .map(([name, f]) => entry(shell, `--${name}`, f.describe));
110
+ }
111
+ if (before.length === 0) {
112
+ return Object.entries(COMMANDS).filter(([name]) => name.startsWith(current)).map(([name, c]) => entry(shell, name, c.describe));
113
+ }
114
+ if (command === 'completion' && before.length === 1)
115
+ return SHELLS.filter(s => s.startsWith(current)).map(s => entry(shell, s));
116
+ return [];
117
+ }
118
+ /**
119
+ * The completion script for `shell`. It registers both `ctt` and `claude-term-tabs`, and runs `program`
120
+ * (ctt's own path) to answer each request. Adapted from yargs' scripts (MIT).
121
+ */
122
+ export function completionScript(shell, program) {
123
+ const ctt = `${shellQuote(program)} ${COMPLETE_FLAG} ${shell}`;
124
+ if (shell === 'zsh') {
125
+ return `#compdef ctt claude-term-tabs
126
+ ###-begin-ctt-completions-###
127
+ #
128
+ # ctt tab completion for zsh. Install: ctt completion zsh >> ~/.zshrc (after compinit)
129
+ #
130
+ _ctt_completions()
131
+ {
132
+ local reply
133
+ local si=$IFS
134
+ IFS=$'\\n' reply=($(${ctt} "\${words[@]:0:$CURRENT}"))
135
+ IFS=$si
136
+ if [[ \${#reply} -gt 0 ]]; then
137
+ _describe 'values' reply
138
+ else
139
+ # nothing to offer: complete file names, after the = of --flag=value
140
+ [[ $PREFIX == --*=* ]] && compset -P '*='
141
+ _default
142
+ fi
143
+ }
144
+ if [[ "\${zsh_eval_context[-1]}" == "loadautofunc" ]]; then
145
+ _ctt_completions "$@"
146
+ else
147
+ compdef _ctt_completions ctt claude-term-tabs
148
+ fi
149
+ ###-end-ctt-completions-###`;
150
+ }
151
+ return `###-begin-ctt-completions-###
152
+ #
153
+ # ctt tab completion for bash 4 or later. Install: ctt completion bash >> ~/.bashrc
154
+ #
155
+ _ctt_completions()
156
+ {
157
+ local cur_word type_list
158
+ cur_word="\${COMP_WORDS[COMP_CWORD]}"
159
+ # bash splits --terminal=w into --terminal, = and w
160
+ [[ $cur_word == = ]] && cur_word=''
161
+ mapfile -t type_list < <(${ctt} "\${COMP_WORDS[@]:0:COMP_CWORD+1}")
162
+ # nothing to offer: leave COMPREPLY empty so bash completes file names
163
+ if [[ \${#type_list[@]} -eq 0 ]]; then
164
+ COMPREPLY=()
165
+ return 0
166
+ fi
167
+ mapfile -t COMPREPLY < <(compgen -W "$( printf '%q ' "\${type_list[@]}" )" -- "\${cur_word}")
168
+ }
169
+ complete -o bashdefault -o default -F _ctt_completions ctt claude-term-tabs
170
+ ###-end-ctt-completions-###`;
171
+ }
package/dist/cache.js ADDED
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { writeOwnerOnly } from "./fsutil.js";
3
+ const CACHE_VERSION = 1;
4
+ /** Digest cache keyed by transcript path, valid while the file's size and mtime are unchanged. */
5
+ export class DigestCache {
6
+ file;
7
+ entries;
8
+ seen = new Set();
9
+ dirty = false;
10
+ constructor(file, entries) {
11
+ this.file = file;
12
+ this.entries = entries;
13
+ }
14
+ /** Load the cache; a missing file is empty, a corrupt one is discarded with one warning. */
15
+ static load(file, warn) {
16
+ let raw;
17
+ try {
18
+ raw = readFileSync(file, 'utf8');
19
+ }
20
+ catch (err) {
21
+ if (err.code !== 'ENOENT')
22
+ warn(`claude-term-tabs: cache unreadable (${err.message}), rebuilding`);
23
+ return new DigestCache(file, new Map());
24
+ }
25
+ try {
26
+ const parsed = JSON.parse(raw);
27
+ if (parsed.version !== CACHE_VERSION || typeof parsed.entries !== 'object' || parsed.entries === null) {
28
+ return new DigestCache(file, new Map());
29
+ }
30
+ return new DigestCache(file, new Map(Object.entries(parsed.entries)));
31
+ }
32
+ catch {
33
+ warn(`claude-term-tabs: cache at ${file} is corrupt, rebuilding`);
34
+ return new DigestCache(file, new Map());
35
+ }
36
+ }
37
+ /** Cached digest for `path`, or the result of `extract` when stale. Missing files are never cached. */
38
+ get(path, extract) {
39
+ let stat;
40
+ try {
41
+ stat = statSync(path);
42
+ }
43
+ catch {
44
+ return extract();
45
+ }
46
+ this.seen.add(path);
47
+ const hit = this.entries.get(path);
48
+ if (hit !== undefined && hit.size === stat.size && hit.mtimeMs === stat.mtimeMs)
49
+ return hit.digest;
50
+ const digest = extract();
51
+ this.entries.set(path, { size: stat.size, mtimeMs: stat.mtimeMs, digest });
52
+ this.dirty = true;
53
+ return digest;
54
+ }
55
+ /** Paths looked up through `get` during this run. */
56
+ touched() {
57
+ return new Set(this.seen);
58
+ }
59
+ /** Drop every entry whose path is not in `paths` (call after a full listing). */
60
+ retainOnly(paths) {
61
+ for (const key of [...this.entries.keys()]) {
62
+ if (!paths.has(key)) {
63
+ this.entries.delete(key);
64
+ this.dirty = true;
65
+ }
66
+ }
67
+ }
68
+ /** Write the cache atomically when anything changed. */
69
+ save() {
70
+ if (!this.dirty && existsSync(this.file))
71
+ return;
72
+ const body = { version: CACHE_VERSION, entries: Object.fromEntries(this.entries) };
73
+ writeOwnerOnly(this.file, JSON.stringify(body));
74
+ this.dirty = false;
75
+ }
76
+ }