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/dist/cli.js ADDED
@@ -0,0 +1,275 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { closeSync, openSync, readSync } from 'node:fs';
3
+ import { sep } from 'node:path';
4
+ import { isatty } from 'node:tty';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parseFlags, SEARCH_FLAGS } from "./args.js";
7
+ import { DigestCache } from "./cache.js";
8
+ import { loadConfig } from "./config.js";
9
+ import { realExec } from "./exec.js";
10
+ import { dirExists as realDirExists, listClosedSessions, resumeCommand } from "./history.js";
11
+ import { collectLiveSessions } from "./live.js";
12
+ import { askHaiku, MAX_LLM_CANDIDATES } from "./llm.js";
13
+ import { lineWriter } from "./output.js";
14
+ import { defaultDirs } from "./paths.js";
15
+ import { formatPickerLine, pick as realPick } from "./picker.js";
16
+ import { clearWinner, rankLocal, tokenize } from "./score.js";
17
+ import { detectAdapter } from "./terminals/adapter.js";
18
+ import { runCommand } from "./commands.js";
19
+ import { listPanesOrWarn } from "./discover.js";
20
+ import { HELP } from "./help.js";
21
+ export { runCommand };
22
+ import { shellQuote } from "./shell.js";
23
+ const EXIT_OK = 0;
24
+ const EXIT_NONE = 1;
25
+ const EXIT_ERROR = 2;
26
+ /** Parse argv; throws on unknown flags. */
27
+ export function parseArgs(argv) {
28
+ const { values, positionals } = parseFlags(argv, SEARCH_FLAGS, { positionals: true });
29
+ return {
30
+ query: positionals.join(' '),
31
+ list: values.list || values.json,
32
+ all: values.all,
33
+ json: values.json,
34
+ noLlm: values['no-llm'],
35
+ terminal: values.terminal,
36
+ help: values.help
37
+ };
38
+ }
39
+ /** Run the search and act on the result. Returns the process exit code. */
40
+ export function run(opts, deps) {
41
+ if (opts.help) {
42
+ deps.stdout(HELP);
43
+ return EXIT_OK;
44
+ }
45
+ try {
46
+ const config = loadConfig(deps.dirs.config);
47
+ const adapter = deps.resolveAdapter(opts.terminal);
48
+ const panes = listPanesOrWarn(adapter, deps);
49
+ const pool = gatherCandidates(opts, deps, panes, config);
50
+ const ctx = { opts, deps, adapter, config, multiWindow: new Set(panes.map(p => p.windowIndex)).size > 1 };
51
+ return search(ctx, pool);
52
+ }
53
+ catch (err) {
54
+ deps.stderr(`claude-term-tabs: ${err.message}`);
55
+ return EXIT_ERROR;
56
+ }
57
+ }
58
+ function search(ctx, pool) {
59
+ const { opts, deps } = ctx;
60
+ if (tokenize(opts.query).length === 0)
61
+ return choose(ctx, byRecency(opts.all ? pool : pool.filter(c => c.live)), new Map());
62
+ const ranked = rankLocal(opts.query, pool, deps.now());
63
+ const winner = clearWinner(ranked);
64
+ if (winner !== undefined)
65
+ return confident(ctx, winner);
66
+ const local = ranked.map(r => r.cand);
67
+ if (opts.noLlm)
68
+ return choose(ctx, local, new Map());
69
+ const sent = dedupe([...local, ...byRecency(pool)]).slice(0, MAX_LLM_CANDIDATES);
70
+ const matches = askHaiku(opts.query, sent, deps.exec, deps.stderr);
71
+ if (matches === undefined) {
72
+ if (local.length > 0)
73
+ return choose(ctx, local, new Map());
74
+ deps.stdout('No local match, and Haiku was unavailable (see the warning above). Try different words or --all.');
75
+ return EXIT_NONE;
76
+ }
77
+ const byId = new Map(sent.map(c => [c.digest.sessionId, c]));
78
+ const found = matches.flatMap(m => byId.get(m.sessionId) ?? []);
79
+ const summaries = new Map(matches.map(m => [m.sessionId, m.summary]));
80
+ if (found.length === 1)
81
+ return confident(ctx, found[0], summaries.get(found[0].digest.sessionId));
82
+ return choose(ctx, found, summaries);
83
+ }
84
+ function choose(ctx, cands, summaries) {
85
+ const { opts, deps } = ctx;
86
+ if (cands.length === 0) {
87
+ deps.stdout(opts.all ? 'No matching sessions.' : 'No matching sessions. Try --all.');
88
+ return EXIT_NONE;
89
+ }
90
+ if (opts.list || !deps.isTty)
91
+ return printList(ctx, cands, summaries);
92
+ const chosen = deps.pick(cands, summaries);
93
+ return chosen === undefined ? EXIT_NONE : act(ctx, chosen, summaries.get(chosen.digest.sessionId));
94
+ }
95
+ function confident(ctx, c, summary) {
96
+ if (c.live)
97
+ return act(ctx, c, summary);
98
+ return choose(ctx, [c], new Map(summary !== undefined ? [[c.digest.sessionId, summary]] : []));
99
+ }
100
+ function act(ctx, c, summary) {
101
+ const { opts, deps, adapter } = ctx;
102
+ if (opts.list)
103
+ return printList(ctx, [c], new Map(summary !== undefined ? [[c.digest.sessionId, summary]] : []));
104
+ if (!c.live)
105
+ return resume(ctx, c);
106
+ if (adapter === undefined || c.pane === undefined) {
107
+ deps.stdout(`${describe(c, summary, deps.now())}`);
108
+ deps.stderr('claude-term-tabs: no supported terminal pane for this session, so not switching');
109
+ return EXIT_OK;
110
+ }
111
+ adapter.focus(c.pane);
112
+ deps.stdout(confirmation(c, ctx.multiWindow));
113
+ return EXIT_OK;
114
+ }
115
+ function resume(ctx, c) {
116
+ const { deps, adapter, config } = ctx;
117
+ const command = resumeCommand(config, c.digest.sessionId);
118
+ if (c.dirGone === true) {
119
+ deps.stdout(`${c.digest.cwd || '(unknown directory)'} no longer exists, and claude --resume only finds a session from the directory it ran in.\n cd ${shellQuote(c.digest.cwd)} && ${command}`);
120
+ return EXIT_NONE;
121
+ }
122
+ if (adapter === undefined) {
123
+ deps.stdout(`cd ${shellQuote(c.digest.cwd)} && ${command}`);
124
+ return EXIT_OK;
125
+ }
126
+ adapter.openPane(c.digest.cwd, command);
127
+ deps.stdout(`→ resuming ${c.digest.title ?? c.digest.sessionId} · ${c.digest.repo} in a new tab`);
128
+ return EXIT_OK;
129
+ }
130
+ function printList(ctx, cands, summaries) {
131
+ const { opts, deps } = ctx;
132
+ const now = deps.now();
133
+ if (opts.json) {
134
+ deps.stdout(JSON.stringify(cands.map(c => toJson(c, summaries.get(c.digest.sessionId))), null, 2));
135
+ }
136
+ else {
137
+ deps.stdout(cands.map(c => describe(c, summaries.get(c.digest.sessionId), now)).join('\n'));
138
+ }
139
+ return EXIT_OK;
140
+ }
141
+ function describe(c, summary, now) {
142
+ const line = formatPickerLine(c, summary, now).split('\t')[1];
143
+ return c.pane === undefined && c.tty !== undefined ? `${line} · ${c.tty}` : line;
144
+ }
145
+ function confirmation(c, multiWindow) {
146
+ const where = [multiWindow ? `window ${c.pane?.windowIndex}` : undefined, `tab ${c.pane?.tabIndex}`].filter(Boolean).join(' · ');
147
+ return `→ ${c.digest.title ?? c.digest.sessionId} · ${c.digest.repo} · ${where}`;
148
+ }
149
+ function toJson(c, summary) {
150
+ const d = c.digest;
151
+ return {
152
+ sessionId: d.sessionId,
153
+ title: d.title,
154
+ repo: d.repo,
155
+ worktree: d.worktree,
156
+ branch: d.branch,
157
+ cwd: d.cwd,
158
+ live: c.live,
159
+ status: c.status,
160
+ lastActive: d.lastActive,
161
+ tty: c.tty,
162
+ window: c.pane?.windowIndex,
163
+ tab: c.pane?.tabIndex,
164
+ dirGone: c.dirGone,
165
+ summary
166
+ };
167
+ }
168
+ function gatherCandidates(opts, deps, panes, config) {
169
+ const cache = DigestCache.load(deps.dirs.cache, deps.stderr);
170
+ const paneByTty = new Map(panes.map(p => [p.tty, p]));
171
+ const live = collectLiveSessions({ sessionsDir: deps.dirs.sessions, projectsDir: deps.dirs.projects, exec: deps.exec, cache })
172
+ .map(c => ({ ...c, pane: c.tty !== undefined ? paneByTty.get(c.tty) : undefined }));
173
+ const closed = opts.all
174
+ ? listClosedSessions({ projectsDir: deps.dirs.projects, liveIds: new Set(live.map(c => c.digest.sessionId)), excludeDirs: config.excludeDirs, cache, dirExists: deps.dirExists })
175
+ : [];
176
+ if (opts.all)
177
+ cache.retainOnly(cache.touched());
178
+ try {
179
+ cache.save();
180
+ }
181
+ catch (err) {
182
+ deps.stderr(`claude-term-tabs: could not save cache: ${err.message}`);
183
+ }
184
+ return [...live, ...closed];
185
+ }
186
+ function byRecency(cands) {
187
+ return [...cands].sort((a, b) => b.digest.lastActive - a.digest.lastActive);
188
+ }
189
+ function dedupe(cands) {
190
+ const seen = new Set();
191
+ return cands.filter(c => !seen.has(c.digest.sessionId) && seen.add(c.digest.sessionId) !== undefined);
192
+ }
193
+ const realExecTty = (cmd, args, input) => {
194
+ if (process.env.CTT_NO_REAL_EXEC === '1')
195
+ return { status: 126, stdout: '', stderr: `${cmd}: blocked by CTT_NO_REAL_EXEC` };
196
+ const r = spawnSync(cmd, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'inherit'] });
197
+ if (r.error)
198
+ return { status: 127, stdout: '', stderr: `${cmd}: ${r.error.message}` };
199
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: '' };
200
+ };
201
+ function promptTty(question) {
202
+ process.stderr.write(question);
203
+ const fd = openSync('/dev/tty', 'r');
204
+ try {
205
+ const buf = Buffer.alloc(256);
206
+ const n = readSync(fd, buf, 0, buf.length, null);
207
+ return buf.subarray(0, n).toString('utf8').split('\n')[0];
208
+ }
209
+ finally {
210
+ closeSync(fd);
211
+ }
212
+ }
213
+ /**
214
+ * The real outside world. Building it runs nothing: whether fzf is installed is checked the first
215
+ * time something asks, so completion requests, which never need it, start no process.
216
+ */
217
+ export function realDeps(exec = realExec) {
218
+ let fzf;
219
+ const deps = {
220
+ exec,
221
+ execTty: realExecTty,
222
+ dirs: defaultDirs,
223
+ stdout: lineWriter(1),
224
+ stderr: lineWriter(2),
225
+ prompt: promptTty,
226
+ now: Date.now,
227
+ // isatty, not process.stdout.isTTY: touching process.stdout makes a piped fd 1 non-blocking.
228
+ isTty: isatty(1),
229
+ get hasFzf() {
230
+ fzf ??= exec('sh', ['-c', 'command -v fzf']).status === 0;
231
+ return fzf;
232
+ },
233
+ dirExists: realDirExists,
234
+ resolveAdapter: override => detectAdapter(process.env, exec, override),
235
+ pick: (cands, summaries) => realPick(cands, summaries, realExecTty, { hasFzf: deps.hasFzf, prompt: promptTty }),
236
+ isAlive: pid => {
237
+ try {
238
+ process.kill(pid, 0);
239
+ return true;
240
+ }
241
+ catch {
242
+ return false;
243
+ }
244
+ },
245
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
246
+ self: [process.execPath, process.argv[1]],
247
+ completionProgram: completionProgram(process.argv[1], import.meta.url),
248
+ shell: process.env.SHELL
249
+ };
250
+ return deps;
251
+ }
252
+ /**
253
+ * What a completion script should run. An installed package (built, so it runs from dist/) is called by name from
254
+ * PATH, because the path it ran from may not last (npx cleans its cache; a version manager keeps one prefix per Node). A clone runs from src/ and keeps
255
+ * its own path, so it works without being on PATH.
256
+ */
257
+ export function completionProgram(argv1, moduleUrl) {
258
+ return fileURLToPath(moduleUrl).endsWith(`${sep}dist${sep}cli.js`) ? 'claude-term-tabs' : argv1;
259
+ }
260
+ /** Entry point used by bin/claude-term-tabs. */
261
+ export async function main(argv) {
262
+ const deps = realDeps();
263
+ const sub = await runCommand(argv, deps);
264
+ if (sub !== undefined)
265
+ return sub;
266
+ let opts;
267
+ try {
268
+ opts = parseArgs(argv);
269
+ }
270
+ catch (err) {
271
+ process.stderr.write(`claude-term-tabs: ${err.message}\n${HELP}\n`);
272
+ return EXIT_ERROR;
273
+ }
274
+ return run(opts, deps);
275
+ }
@@ -0,0 +1,150 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { COMMANDS, completionScript, isCommand, isShell, PAUSE_FLAGS, parseFlags, RESUME_FLAGS, SHELLS } from "./args.js";
4
+ import { answerCompletion, COMPLETE_FLAG } from "./complete.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { HELP } from "./help.js";
7
+ import { liveWithPanes } from "./discover.js";
8
+ import { inFrontOf } from "./foreground.js";
9
+ import { transcriptFor } from "./live.js";
10
+ import { pauseSummary, sendStop, startPause } from "./pause/run.js";
11
+ import { runStragglerUi } from "./pause/ui.js";
12
+ import { planResume, runResume } from "./resume.js";
13
+ import { latestSnapshot } from "./snapshot.js";
14
+ const EXIT_OK = 0;
15
+ const EXIT_NONE = 1;
16
+ const EXIT_USAGE = 2;
17
+ /** Run a subcommand when argv[0] names one; undefined means "not a subcommand, search instead". */
18
+ export async function runCommand(argv, deps) {
19
+ const [name, ...rest] = argv;
20
+ if (isCommand(name) && asksHelp(rest))
21
+ return showHelp(deps);
22
+ try {
23
+ switch (name) {
24
+ case 'pause': return await pause(rest, deps);
25
+ case 'resume': return resume(rest, deps);
26
+ case 'completion': return completion(rest, deps);
27
+ case COMPLETE_FLAG: return answerCompletion(rest, deps);
28
+ case '_focus': return focus(rest, deps);
29
+ case '_send-stop': return sendStopCommand(rest, deps);
30
+ default: return undefined;
31
+ }
32
+ }
33
+ catch (err) {
34
+ deps.stderr(`claude-term-tabs: ${err.message}`);
35
+ return EXIT_USAGE;
36
+ }
37
+ }
38
+ async function pause(argv, deps) {
39
+ const { values } = parseFlags(argv, PAUSE_FLAGS);
40
+ const config = loadConfig(deps.dirs.config);
41
+ const timeoutMinutes = values.timeout !== undefined ? positive(values.timeout, '--timeout') : config.pauseTimeoutMinutes;
42
+ const adapter = deps.resolveAdapter();
43
+ const pdeps = {
44
+ now: deps.now,
45
+ adapter,
46
+ collect: () => liveWithPanes(deps, adapter),
47
+ isAlive: deps.isAlive,
48
+ transcriptOf: c => transcriptFor(deps.dirs.projects, c.digest.cwd, c.digest.sessionId),
49
+ sizeOf: p => statSync(p).size,
50
+ snapshotDir: deps.dirs.snapshots,
51
+ config,
52
+ warn: deps.stderr,
53
+ onSnapshot: path => { snapshotPath = path; },
54
+ inFront: c => frontOf(deps, c)
55
+ };
56
+ let snapshotPath = '';
57
+ const run = startPause(pdeps, { autoClose: values['auto-close'] });
58
+ deps.stdout(`Recorded ${run.tracked.length} sessions in ${snapshotPath}; sent the stop prompt. Waiting up to ${timeoutMinutes} min.`);
59
+ const end = await runStragglerUi(run, pdeps, {
60
+ hasFzf: deps.hasFzf,
61
+ isTty: deps.isTty,
62
+ timeoutMs: timeoutMinutes * 60_000,
63
+ self: deps.self,
64
+ write: deps.stdout,
65
+ sleep: deps.sleep,
66
+ onPick: id => { focus([id], deps); }
67
+ });
68
+ const summary = pauseSummary(end);
69
+ deps.stdout(summary.text);
70
+ return summary.exitCode;
71
+ }
72
+ function resume(argv, deps) {
73
+ const { values } = parseFlags(argv, RESUME_FLAGS);
74
+ const config = loadConfig(deps.dirs.config);
75
+ const snap = values.snapshot !== undefined ? readSnapshot(values.snapshot) : latestSnapshot(deps.dirs.snapshots);
76
+ if (snap === undefined) {
77
+ deps.stdout('No snapshot found. Run ctt pause first.');
78
+ return EXIT_NONE;
79
+ }
80
+ const adapter = deps.resolveAdapter();
81
+ const liveIds = new Set(liveWithPanes(deps, adapter).map(c => c.digest.sessionId));
82
+ const steps = planResume(snap, liveIds, deps.dirExists, config, { noPrompt: values['no-prompt'] });
83
+ deps.stdout(`Snapshot ${snap.createdAt}: ${steps.length} sessions`);
84
+ return runResume(steps, adapter, { dryRun: values['dry-run'] }, deps.stdout);
85
+ }
86
+ function showHelp(deps) {
87
+ deps.stdout(HELP);
88
+ return EXIT_OK;
89
+ }
90
+ function asksHelp(argv) {
91
+ const end = argv.indexOf('--');
92
+ return (end === -1 ? argv : argv.slice(0, end)).some(a => a === '-h' || a === '--help');
93
+ }
94
+ /** `ctt completion [bash|zsh]` prints the script; without a shell, $SHELL decides when it is bash or zsh. */
95
+ function completion(argv, deps) {
96
+ const { positionals } = parseFlags(argv, COMMANDS.completion.flags, { positionals: true });
97
+ if (positionals.length > 1)
98
+ throw new Error(`Unexpected argument '${positionals[1]}'`);
99
+ const shell = positionals[0] ?? basename(deps.shell ?? '');
100
+ if (!isShell(shell)) {
101
+ const where = positionals[0] === undefined ? ' in $SHELL' : '';
102
+ throw new Error(`unknown shell "${shell}"${where} (supported: ${SHELLS.join(', ')}); run ${SHELLS.map(s => `ctt completion ${s}`).join(' or ')}`);
103
+ }
104
+ deps.stdout(completionScript(shell, deps.completionProgram ?? deps.self[1] ?? 'ctt'));
105
+ return EXIT_OK;
106
+ }
107
+ function focus(argv, deps) {
108
+ const id = requireId(argv);
109
+ const adapter = deps.resolveAdapter();
110
+ const c = liveWithPanes(deps, adapter).find(x => x.digest.sessionId === id);
111
+ if (c?.pane === undefined || adapter === undefined) {
112
+ deps.stderr(`claude-term-tabs: no reachable pane for session ${id}`);
113
+ return EXIT_NONE;
114
+ }
115
+ adapter.focus(c.pane);
116
+ return EXIT_OK;
117
+ }
118
+ function sendStopCommand(argv, deps) {
119
+ const id = requireId(argv);
120
+ const adapter = deps.resolveAdapter();
121
+ const c = liveWithPanes(deps, adapter).find(x => x.digest.sessionId === id);
122
+ if (c === undefined) {
123
+ deps.stderr(`claude-term-tabs: session ${id} is not running`);
124
+ return EXIT_NONE;
125
+ }
126
+ return sendStop(c, adapter, loadConfig(deps.dirs.config).stopPrompt, deps.stderr, x => frontOf(deps, x)) ? EXIT_OK : EXIT_NONE;
127
+ }
128
+ function frontOf(deps, c) {
129
+ return c.tty !== undefined ? inFrontOf(deps.exec, c.tty) : 'other';
130
+ }
131
+ function requireId(argv) {
132
+ const { positionals } = parseFlags(argv, {}, { positionals: true });
133
+ if (positionals.length !== 1)
134
+ throw new Error('expected exactly one session id');
135
+ return positionals[0];
136
+ }
137
+ function readSnapshot(path) {
138
+ try {
139
+ return JSON.parse(readFileSync(path, 'utf8'));
140
+ }
141
+ catch (err) {
142
+ throw new Error(`cannot read snapshot ${path}: ${err.message}`);
143
+ }
144
+ }
145
+ function positive(raw, flag) {
146
+ const n = Number(raw);
147
+ if (!Number.isFinite(n) || n <= 0)
148
+ throw new Error(`${flag} must be a positive number of minutes`);
149
+ return n;
150
+ }
@@ -0,0 +1,18 @@
1
+ import { COMPLETE_FLAG, completions, isShell, SHELLS } from "./args.js";
2
+ export { COMPLETE_FLAG };
3
+ /**
4
+ * Answer a completion script's `ctt --complete <shell> <words>` (`argv` is what follows the flag).
5
+ * bin/claude-term-tabs calls this before loading the rest of ctt, so a Tab press stays fast. Returns the
6
+ * exit code.
7
+ */
8
+ export function answerCompletion(argv, out) {
9
+ const [shell, ...words] = argv;
10
+ if (!isShell(shell)) {
11
+ out.stderr(`claude-term-tabs: ${COMPLETE_FLAG} needs ${SHELLS.join(' or ')}, then the words to complete`);
12
+ return 2;
13
+ }
14
+ const offered = completions(shell, words);
15
+ if (offered.length > 0)
16
+ out.stdout(offered.join('\n'));
17
+ return 0;
18
+ }
package/dist/config.js ADDED
@@ -0,0 +1,76 @@
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ /** Prompt typed into every session by `ctt pause`. */
5
+ export const DEFAULT_STOP_PROMPT = "Pausing for a machine restart. Stop at the nearest safe point: start nothing new, and do not push, merge, apply, post or transition anything that was not already mid-flight. Leave no file half-edited; do not commit unless that was already the plan. Background jobs, Monitors, /loop wakeups and subagents will die with the restart.\n\nThen reply with a message headed HANDOFF (under 20 lines):\n- Goal: what this session is for (ticket/PR refs)\n- Done: what is finished, with PR numbers, commits, ticket states\n- In flight: branch, worktree path, uncommitted changes, anything half-done\n- Lost on restart: background watches/monitors/loops/agents and how to restart them\n- Next step: the exact next action\n- Open questions: anything you need from me\n\nThen stop and wait.\n";
6
+ /** Prompt passed to every session reopened by `ctt resume`. */
7
+ export const DEFAULT_RESUME_PROMPT = "Resumed after a machine restart. Find your most recent HANDOFF message above and pick up from its \"Next step\". Before acting, re-check live state, since anything may have moved while we were down: git branch and status (and worktree path if any), PR and CI status, and Jira status for any ticket involved. Restart any background watches, monitors or loops the handoff lists. Then give me a 3-line status and continue. If there is no HANDOFF message, summarize where we left off and ask before doing anything.\n";
8
+ const DEFAULT_PAUSE_TIMEOUT_MINUTES = 10;
9
+ /** Load config; a missing file gives defaults, anything unreadable or invalid throws. */
10
+ export function loadConfig(path) {
11
+ let raw;
12
+ try {
13
+ raw = readFileSync(path, 'utf8');
14
+ }
15
+ catch (err) {
16
+ if (err.code === 'ENOENT')
17
+ return withDefaults({});
18
+ throw new Error(`cannot read config ${path}: ${err.message}`);
19
+ }
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(raw);
23
+ }
24
+ catch (err) {
25
+ throw new Error(`invalid config ${path}: ${err.message}`);
26
+ }
27
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
28
+ throw new Error(`invalid config ${path}: not an object`);
29
+ return withDefaults(validate(parsed, path));
30
+ }
31
+ function validate(o, path) {
32
+ const bad = (key, want) => new Error(`invalid config ${path}: ${key} must be ${want}`);
33
+ if (o.resumeCommand !== undefined && typeof o.resumeCommand !== 'string')
34
+ throw bad('resumeCommand', 'a string');
35
+ for (const key of ['stopPrompt', 'resumePrompt']) {
36
+ if (o[key] !== undefined && (typeof o[key] !== 'string' || o[key] === ''))
37
+ throw bad(key, 'a non-empty string');
38
+ }
39
+ const t = o.pauseTimeoutMinutes;
40
+ if (t !== undefined && (typeof t !== 'number' || !Number.isFinite(t) || t <= 0))
41
+ throw bad('pauseTimeoutMinutes', 'a positive number');
42
+ const x = o.excludeDirs;
43
+ let excludeDirs;
44
+ if (x !== undefined) {
45
+ if (!Array.isArray(x) || !x.every(d => typeof d === 'string'))
46
+ throw bad('excludeDirs', 'a list of directories');
47
+ const dirs = x.map(expandDir);
48
+ if (dirs.some(d => d === undefined))
49
+ throw bad('excludeDirs', 'absolute paths or paths starting with ~/');
50
+ // Claude Code records the physical cwd, so a dir reached through a symlink (macOS /tmp) also matches by its real path.
51
+ excludeDirs = [...new Set(dirs.flatMap(d => [d, realpathOr(d)]))];
52
+ }
53
+ return { ...o, ...(excludeDirs !== undefined && { excludeDirs }) };
54
+ }
55
+ /** `~/x` → `$HOME/x`, resolved (normalized, no trailing slash); undefined when not absolute. */
56
+ function expandDir(d) {
57
+ const p = d === '~' || d.startsWith('~/') ? join(homedir(), d.slice(1)) : d;
58
+ return isAbsolute(p) ? resolve(p) : undefined;
59
+ }
60
+ function realpathOr(d) {
61
+ try {
62
+ return realpathSync(d);
63
+ }
64
+ catch {
65
+ return d;
66
+ }
67
+ }
68
+ function withDefaults(c) {
69
+ return {
70
+ resumeCommand: c.resumeCommand,
71
+ stopPrompt: c.stopPrompt ?? DEFAULT_STOP_PROMPT,
72
+ resumePrompt: c.resumePrompt ?? DEFAULT_RESUME_PROMPT,
73
+ pauseTimeoutMinutes: c.pauseTimeoutMinutes ?? DEFAULT_PAUSE_TIMEOUT_MINUTES,
74
+ excludeDirs: c.excludeDirs ?? []
75
+ };
76
+ }
@@ -0,0 +1,29 @@
1
+ import { DigestCache } from "./cache.js";
2
+ import { collectLiveSessions } from "./live.js";
3
+ /** Live sessions with their panes attached; a pane listing failure warns and attaches none. */
4
+ export function liveWithPanes(deps, adapter) {
5
+ const cache = DigestCache.load(deps.dirs.cache, deps.stderr);
6
+ const panes = listPanesOrWarn(adapter, deps);
7
+ const byTty = new Map(panes.map(p => [p.tty, p]));
8
+ const live = collectLiveSessions({ sessionsDir: deps.dirs.sessions, projectsDir: deps.dirs.projects, exec: deps.exec, cache })
9
+ .map(c => ({ ...c, pane: c.tty !== undefined ? byTty.get(c.tty) : undefined }));
10
+ try {
11
+ cache.save();
12
+ }
13
+ catch (err) {
14
+ deps.stderr(`claude-term-tabs: could not save cache: ${err.message}`);
15
+ }
16
+ return live;
17
+ }
18
+ /** Panes from the adapter, or none (with a warning) when listing fails. */
19
+ export function listPanesOrWarn(adapter, deps) {
20
+ if (adapter === undefined)
21
+ return [];
22
+ try {
23
+ return adapter.listPanes();
24
+ }
25
+ catch (err) {
26
+ deps.stderr(`claude-term-tabs: ${err.message}`);
27
+ return [];
28
+ }
29
+ }
package/dist/exec.js ADDED
@@ -0,0 +1,13 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ const MAX_BUFFER = 64 * 1024 * 1024;
3
+ /** Real Exec backed by spawnSync. Spawn failure returns status 127, a timeout 124, CTT_NO_REAL_EXEC=1 blocks with 126. */
4
+ export const realExec = (cmd, args, input, opts) => {
5
+ if (process.env.CTT_NO_REAL_EXEC === '1')
6
+ return { status: 126, stdout: '', stderr: `${cmd}: blocked by CTT_NO_REAL_EXEC` };
7
+ const r = spawnSync(cmd, args, { encoding: 'utf8', input, maxBuffer: MAX_BUFFER, timeout: opts?.timeoutMs });
8
+ if (r.error?.code === 'ETIMEDOUT')
9
+ return { status: 124, stdout: '', stderr: `${cmd}: timed out` };
10
+ if (r.error)
11
+ return { status: 127, stdout: '', stderr: `${cmd}: ${r.error.message}` };
12
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
13
+ };
@@ -0,0 +1,19 @@
1
+ import { basename } from 'node:path';
2
+ const SHELLS = new Set(['zsh', 'bash', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh']);
3
+ /** Classify `ps -t <tty> -o pgid=,tpgid=,comm=` output by the foreground process group. */
4
+ export function foregroundKind(psOutput) {
5
+ const front = psOutput.split('\n').flatMap(line => {
6
+ const m = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
7
+ return m !== null && m[1] === m[2] ? [basename(m[3]).replace(/^-/, '')] : [];
8
+ });
9
+ if (front.includes('claude'))
10
+ return 'claude';
11
+ if (front.length > 0 && front.every(c => SHELLS.has(c)))
12
+ return 'shell';
13
+ return 'other';
14
+ }
15
+ /** What is in front of `tty` right now; any failure reads as 'other', which blocks typing. */
16
+ export function inFrontOf(exec, tty) {
17
+ const r = exec('ps', ['-t', tty.replace(/^\/dev\//, ''), '-o', 'pgid=,tpgid=,comm=']);
18
+ return r.status === 0 ? foregroundKind(r.stdout) : 'other';
19
+ }
package/dist/fsutil.js ADDED
@@ -0,0 +1,18 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, closeSync, constants, mkdirSync, openSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ /** Atomically write `text` to `file` as 0600, creating or tightening its directory to 0700. */
5
+ export function writeOwnerOnly(file, text) {
6
+ const dir = dirname(file);
7
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
8
+ chmodSync(dir, 0o700);
9
+ const tmp = `${file}.${randomBytes(8).toString('hex')}.tmp`;
10
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
11
+ try {
12
+ writeFileSync(fd, text);
13
+ }
14
+ finally {
15
+ closeSync(fd);
16
+ }
17
+ renameSync(tmp, file);
18
+ }