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.
@@ -0,0 +1,116 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ const DEFAULT_TAIL_BYTES = 128 * 1024;
3
+ const HANDOFF_LINE = /^\s*(?:#+\s*|\*\*)?HANDOFF\b(?!S)/m;
4
+ /**
5
+ * Text of the newest assistant transcript entry that has a text block, when it was written at or
6
+ * after `sinceMs`. Undefined when the newest such entry is older, none exists, or the file is missing.
7
+ */
8
+ export function latestAssistantText(path, sinceMs, opts = {}) {
9
+ let fd;
10
+ try {
11
+ fd = openSync(path, 'r');
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ try {
17
+ const { size } = fstatSync(fd);
18
+ let window = Math.max(1, opts.tailBytes ?? DEFAULT_TAIL_BYTES);
19
+ for (;;) {
20
+ const start = Math.max(0, size - window);
21
+ const found = newestAssistantText(readRange(fd, start, size - start), start > 0);
22
+ if (found !== undefined)
23
+ return found.at >= sinceMs ? found.text : undefined;
24
+ if (start === 0)
25
+ return undefined;
26
+ window *= 2;
27
+ }
28
+ }
29
+ finally {
30
+ closeSync(fd);
31
+ }
32
+ }
33
+ /** True when some line of `text` starts with a HANDOFF heading. */
34
+ export function hasHandoff(text) {
35
+ return text !== undefined && HANDOFF_LINE.test(text);
36
+ }
37
+ function newestAssistantText(chunk, dropFirst) {
38
+ const lines = chunk.split('\n');
39
+ const usable = dropFirst ? lines.slice(1) : lines;
40
+ for (let i = usable.length - 1; i >= 0; i--) {
41
+ const entry = parse(usable[i]);
42
+ if (entry?.type !== 'assistant')
43
+ continue;
44
+ const text = textOf(entry.message);
45
+ const at = Date.parse(String(entry.timestamp));
46
+ if (text !== undefined && Number.isFinite(at))
47
+ return { text, at };
48
+ }
49
+ return undefined;
50
+ }
51
+ function parse(line) {
52
+ if (line.trim() === '')
53
+ return undefined;
54
+ try {
55
+ const v = JSON.parse(line);
56
+ return typeof v === 'object' && v !== null ? v : undefined;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ function textOf(message) {
63
+ const content = message?.content;
64
+ if (typeof content === 'string')
65
+ return content;
66
+ if (!Array.isArray(content))
67
+ return undefined;
68
+ const texts = content
69
+ .filter((b) => typeof b === 'object' && b !== null && b.type === 'text' && typeof b.text === 'string')
70
+ .map(b => b.text);
71
+ return texts.length === 0 ? undefined : texts.join('\n');
72
+ }
73
+ function readRange(fd, start, length) {
74
+ const buf = Buffer.alloc(length);
75
+ const read = readSync(fd, buf, 0, length, start);
76
+ return buf.subarray(0, read).toString('utf8');
77
+ }
78
+ /**
79
+ * Newest assistant text written after the newest user message that starts with `promptStart` and is
80
+ * timestamped at or after `sinceMs`. Undefined when that prompt message, or any reply to it, is missing.
81
+ */
82
+ export function latestAssistantAfterPrompt(path, promptStart, sinceMs, opts = {}) {
83
+ let fd;
84
+ try {
85
+ fd = openSync(path, 'r');
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ try {
91
+ const { size } = fstatSync(fd);
92
+ let window = Math.max(1, opts.tailBytes ?? DEFAULT_TAIL_BYTES);
93
+ for (;;) {
94
+ const start = Math.max(0, size - window);
95
+ const lines = readRange(fd, start, size - start).split('\n');
96
+ const entries = (start > 0 ? lines.slice(1) : lines).flatMap(l => parse(l) ?? []);
97
+ const promptAt = findLastIndex(entries, e => e.type === 'user' && Date.parse(String(e.timestamp)) >= sinceMs && (textOf(e.message) ?? '').trimStart().startsWith(promptStart));
98
+ if (promptAt >= 0) {
99
+ const replies = entries.slice(promptAt + 1).filter(e => e.type === 'assistant').map(e => textOf(e.message)).filter((t) => t !== undefined);
100
+ return replies.at(-1);
101
+ }
102
+ if (start === 0)
103
+ return undefined;
104
+ window *= 2;
105
+ }
106
+ }
107
+ finally {
108
+ closeSync(fd);
109
+ }
110
+ }
111
+ function findLastIndex(items, pred) {
112
+ for (let i = items.length - 1; i >= 0; i--)
113
+ if (pred(items[i]))
114
+ return i;
115
+ return -1;
116
+ }
package/dist/help.js ADDED
@@ -0,0 +1,38 @@
1
+ import { COMMANDS, SEARCH_FLAGS } from "./args.js";
2
+ const MIN_COLUMN = 17;
3
+ /**
4
+ * One line per flag, from the same table that parsing and tab completion use. Descriptions start in a
5
+ * column at least 17 wide and two spaces past the longest label. Subcommand sections leave out
6
+ * -h/--help, which the search section already lists.
7
+ */
8
+ export function flagLines(flags, opts = {}) {
9
+ const rows = Object.entries(flags).filter(([name]) => opts.help !== false || name !== 'help').map(([name, f]) => [`${f.short !== undefined ? `-${f.short}, ` : ''}--${name}${f.value !== undefined ? ` ${f.value}` : ''}`, f.describe]);
10
+ const column = Math.max(MIN_COLUMN, ...rows.map(([label]) => label.length + 2));
11
+ return rows.map(([label, describe]) => ` ${label.padEnd(column)}${describe}`).join('\n');
12
+ }
13
+ /** `ctt pause [--auto-close] [--timeout MIN]`, from a subcommand's table; -h/--help left out. */
14
+ function usage(name) {
15
+ const c = COMMANDS[name];
16
+ const flags = Object.entries(c.flags).filter(([flag]) => flag !== 'help').map(([flag, f]) => `[--${flag}${f.value !== undefined ? ` ${f.value}` : ''}]`);
17
+ return ['ctt', name, ...flags, ...(c.args !== undefined ? [c.args] : [])].join(' ');
18
+ }
19
+ /** Usage text for ctt and its subcommands. */
20
+ export const HELP = `usage: ctt [query...] [options]
21
+ ${Object.keys(COMMANDS).map(name => ` ${usage(name)}`).join('\n')}
22
+
23
+ Find a Claude Code session by what you remember and switch to its tab.
24
+
25
+ ${flagLines(SEARCH_FLAGS)}
26
+
27
+ ctt pause: ${COMMANDS.pause.describe}.
28
+ ${flagLines(COMMANDS.pause.flags, { help: false })}
29
+
30
+ ctt resume: ${COMMANDS.resume.describe}.
31
+ ${flagLines(COMMANDS.resume.flags, { help: false })}
32
+
33
+ ctt completion: ${COMMANDS.completion.describe}.
34
+ ctt completion zsh >> ~/.zshrc, or ctt completion bash >> ~/.bashrc (bash 4 or later)
35
+
36
+ Use ctt -- pause to search for the word "pause".
37
+
38
+ Config: ~/.config/claude-term-tabs/config.json {"resumeCommand": "claude --resume {id}", "excludeDirs": ["~/scratch"]}`;
@@ -0,0 +1,46 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { isAbsolute, join, normalize, sep } from 'node:path';
3
+ import { extractDigest } from "./transcript.js";
4
+ const DEFAULT_RESUME = 'claude --resume {id}';
5
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
6
+ /** Closed sessions: every top-level transcript under projectsDir whose id is not live and whose latest cwd is not excluded. */
7
+ export function listClosedSessions(opts) {
8
+ return readdirSync(opts.projectsDir, { withFileTypes: true })
9
+ .filter(d => d.isDirectory())
10
+ .flatMap(d => readdirSync(join(opts.projectsDir, d.name), { withFileTypes: true })
11
+ .filter(f => f.isFile() && f.name.endsWith('.jsonl'))
12
+ .map(f => ({ path: join(opts.projectsDir, d.name, f.name), sessionId: f.name.slice(0, -'.jsonl'.length) })))
13
+ .filter(t => !opts.liveIds.has(t.sessionId))
14
+ .map(t => opts.cache.get(t.path, () => extractDigest(t.path, { sessionId: t.sessionId })))
15
+ .filter(digest => !isExcluded(digest.cwd, opts.excludeDirs))
16
+ .map(digest => ({ digest, live: false, status: 'unknown', dirGone: !isResumableDir(digest.cwd, opts.dirExists) }));
17
+ }
18
+ /** True when cwd is one of `dirs` or below one of them. An unknown (empty) cwd is never excluded. */
19
+ export function isExcluded(cwd, dirs) {
20
+ if (cwd === '')
21
+ return false;
22
+ const c = normalize(cwd);
23
+ return dirs.some(d => c === d || c.startsWith(d.endsWith(sep) ? d : d + sep));
24
+ }
25
+ /** Shell command that resumes a session, from config `resumeCommand` (must contain `{id}`). */
26
+ export function resumeCommand(config, sessionId) {
27
+ const template = config.resumeCommand ?? DEFAULT_RESUME;
28
+ if (!template.includes('{id}'))
29
+ throw new Error('config resumeCommand must contain {id}');
30
+ if (!SESSION_ID.test(sessionId))
31
+ throw new Error(`refusing to resume unexpected session id "${sessionId}"`);
32
+ return template.replaceAll('{id}', sessionId);
33
+ }
34
+ /** True when `p` is an existing directory. */
35
+ export function dirExists(p) {
36
+ try {
37
+ return statSync(p).isDirectory();
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /** True when cwd is an absolute, normalized path to an existing directory. */
44
+ export function isResumableDir(cwd, exists) {
45
+ return cwd !== '' && isAbsolute(cwd) && normalize(cwd) === cwd && exists(cwd);
46
+ }
package/dist/live.js ADDED
@@ -0,0 +1,86 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { transcriptPath } from "./paths.js";
4
+ import { extractDigest } from "./transcript.js";
5
+ const STATUSES = new Set(['idle', 'busy', 'shell', 'waiting']);
6
+ const PID_REUSE_SLACK_MS = 120_000;
7
+ /** Live interactive Claude Code sessions, joined with their tty and digest. Throws when `ps` fails. */
8
+ export function collectLiveSessions(opts) {
9
+ const procs = listProcs(opts.exec, (opts.now ?? Date.now)());
10
+ return readPidFiles(opts.sessionsDir)
11
+ .filter(f => f.entrypoint === 'cli' && isSameProcess(f, procs.get(f.pid)))
12
+ .map(f => {
13
+ const path = transcriptFor(opts.projectsDir, f.cwd, f.sessionId);
14
+ const digest = opts.cache.get(path, () => extractDigest(path, { sessionId: f.sessionId, cwd: f.cwd }));
15
+ return {
16
+ digest,
17
+ live: true,
18
+ pid: f.pid,
19
+ status: toStatus(f.status),
20
+ tty: procs.get(f.pid)?.tty
21
+ };
22
+ });
23
+ }
24
+ function isSameProcess(f, p) {
25
+ if (p === undefined || basename(p.comm) !== 'claude')
26
+ return false;
27
+ return f.startedAt === undefined || p.startedMs <= f.startedAt + PID_REUSE_SLACK_MS;
28
+ }
29
+ function listProcs(exec, now) {
30
+ const r = exec('ps', ['-axo', 'pid=,tty=,etime=,comm=']);
31
+ if (r.status !== 0)
32
+ throw new Error(`ps failed (${r.status}): ${r.stderr.trim()}`);
33
+ const rows = r.stdout.split('\n').flatMap(line => {
34
+ const m = /^\s*(\d+)\s+(\S+)\s+([\d:-]+)\s+(.+?)\s*$/.exec(line);
35
+ if (m === null)
36
+ return [];
37
+ const tty = m[2] === '??' || m[2] === '-' ? undefined : `/dev/${m[2]}`;
38
+ return [[Number(m[1]), { tty, comm: m[4], startedMs: now - etimeMs(m[3]) }]];
39
+ });
40
+ return new Map(rows);
41
+ }
42
+ function etimeMs(etime) {
43
+ const [days, clock] = etime.includes('-') ? etime.split('-') : ['0', etime];
44
+ const parts = clock.split(':').map(Number).reverse();
45
+ const [s = 0, m = 0, h = 0] = parts;
46
+ return (((Number(days) * 24 + h) * 60 + m) * 60 + s) * 1000;
47
+ }
48
+ function readPidFiles(dir) {
49
+ let names;
50
+ try {
51
+ names = readdirSync(dir);
52
+ }
53
+ catch (err) {
54
+ if (err.code === 'ENOENT')
55
+ return [];
56
+ throw err;
57
+ }
58
+ return names
59
+ .filter(name => /^\d+\.json$/.test(name))
60
+ .flatMap(name => {
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(join(dir, name), 'utf8'));
63
+ if (typeof parsed.pid !== 'number' || typeof parsed.sessionId !== 'string' || typeof parsed.cwd !== 'string')
64
+ return [];
65
+ return [parsed];
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ });
71
+ }
72
+ /** Transcript path for a session, falling back to a search by id across project directories. */
73
+ export function transcriptFor(projectsDir, cwd, sessionId) {
74
+ const direct = transcriptPath(projectsDir, cwd, sessionId);
75
+ if (existsSync(direct))
76
+ return direct;
77
+ for (const dir of readdirSync(projectsDir)) {
78
+ const candidate = join(projectsDir, dir, `${sessionId}.jsonl`);
79
+ if (existsSync(candidate))
80
+ return candidate;
81
+ }
82
+ return direct;
83
+ }
84
+ function toStatus(s) {
85
+ return s !== undefined && STATUSES.has(s) ? s : 'unknown';
86
+ }
package/dist/llm.js ADDED
@@ -0,0 +1,105 @@
1
+ /** Most candidates sent to Haiku in one call. */
2
+ export const MAX_LLM_CANDIDATES = 15;
3
+ const FIELD_CHARS = 120;
4
+ const HAIKU_TIMEOUT_MS = 20_000;
5
+ const SYSTEM_PROMPT = [
6
+ 'You match a user\'s loose description to coding sessions.',
7
+ 'Each session line is: id | repo | branch | title | last prompt.',
8
+ 'Return only sessions that plausibly match, best first, each with a summary of at most 12 words.',
9
+ 'Return an empty list when none match.'
10
+ ].join(' ');
11
+ const SCHEMA = JSON.stringify({
12
+ type: 'object',
13
+ properties: {
14
+ matches: {
15
+ type: 'array',
16
+ items: {
17
+ type: 'object',
18
+ properties: { sessionId: { type: 'string' }, summary: { type: 'string' } },
19
+ required: ['sessionId', 'summary']
20
+ }
21
+ }
22
+ },
23
+ required: ['matches']
24
+ });
25
+ /** CLI args and stdin for a minimal, unsaved `claude -p` Haiku call. */
26
+ export function buildHaikuArgs(query, cands) {
27
+ const args = [
28
+ '-p',
29
+ '--model', 'haiku',
30
+ '--no-session-persistence',
31
+ '--tools', '',
32
+ '--strict-mcp-config',
33
+ '--setting-sources', '',
34
+ '--output-format', 'json',
35
+ '--json-schema', SCHEMA,
36
+ '--system-prompt', SYSTEM_PROMPT
37
+ ];
38
+ const lines = cands.slice(0, MAX_LLM_CANDIDATES).map(c => {
39
+ const d = c.digest;
40
+ return [d.sessionId, d.worktree !== undefined ? `${d.repo}/${d.worktree}` : d.repo, d.branch, d.title, d.lastPrompt]
41
+ .map(v => clean(v)).join(' | ');
42
+ });
43
+ return { args, input: `Description: ${clean(query)}\n\nSessions:\n${lines.join('\n')}\n` };
44
+ }
45
+ /** Parse `claude -p --output-format json` output into matches with known ids. Throws on malformed output. */
46
+ export function parseHaikuRanking(stdout, knownIds) {
47
+ let envelope;
48
+ try {
49
+ envelope = JSON.parse(stdout);
50
+ }
51
+ catch {
52
+ throw new Error('Haiku output was not JSON');
53
+ }
54
+ const payload = envelope.structured_output ?? parseResultText(envelope.result);
55
+ const matches = payload?.matches;
56
+ if (!Array.isArray(matches))
57
+ throw new Error('Haiku output had no matches array');
58
+ const known = matches.flatMap(m => {
59
+ const id = m?.sessionId;
60
+ const summary = m?.summary;
61
+ return typeof id === 'string' && knownIds.has(id) ? [{ sessionId: id, summary: typeof summary === 'string' ? summary : '' }] : [];
62
+ });
63
+ if (matches.length > 0 && known.length === 0)
64
+ throw new Error('Haiku returned only unknown session ids');
65
+ return known;
66
+ }
67
+ /** Ask Haiku to rank candidates. Returns undefined (after warning) when the call fails in any way. */
68
+ export function askHaiku(query, cands, exec, warn) {
69
+ const sent = cands.slice(0, MAX_LLM_CANDIDATES);
70
+ const { args, input } = buildHaikuArgs(query, sent);
71
+ const r = exec('claude', args, input, { timeoutMs: HAIKU_TIMEOUT_MS });
72
+ if (r.status === 124) {
73
+ warn(`claude-term-tabs: Haiku call timed out after ${HAIKU_TIMEOUT_MS / 1000} s; using local ranking`);
74
+ return undefined;
75
+ }
76
+ if (r.status === 127) {
77
+ warn('claude-term-tabs: claude not found, using local ranking');
78
+ return undefined;
79
+ }
80
+ if (r.status !== 0) {
81
+ warn(`claude-term-tabs: Haiku call failed (${r.status}): ${(r.stderr || r.stdout).trim().slice(0, 200)}; using local ranking`);
82
+ return undefined;
83
+ }
84
+ try {
85
+ return parseHaikuRanking(r.stdout, new Set(sent.map(c => c.digest.sessionId)));
86
+ }
87
+ catch (err) {
88
+ warn(`claude-term-tabs: ${err.message}; using local ranking`);
89
+ return undefined;
90
+ }
91
+ }
92
+ function parseResultText(result) {
93
+ if (typeof result !== 'string')
94
+ return undefined;
95
+ const body = result.replace(/^\s*```(?:json)?\s*/, '').replace(/\s*```\s*$/, '');
96
+ try {
97
+ return JSON.parse(body);
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ function clean(v) {
104
+ return (v ?? '').replace(/[\s|]+/g, ' ').trim().slice(0, FIELD_CHARS);
105
+ }
package/dist/output.js ADDED
@@ -0,0 +1,30 @@
1
+ import { writeSync } from 'node:fs';
2
+ const pause = new Int32Array(new SharedArrayBuffer(4));
3
+ /**
4
+ * A line writer for fd that writes every byte. A short write or EAGAIN (a non-blocking pipe that is
5
+ * full) is retried; once the reader has gone (EPIPE, as in `ctt --json | head`), the rest of the
6
+ * output is dropped without an error and ctt carries on.
7
+ */
8
+ export function lineWriter(fd) {
9
+ let closed = false;
10
+ return s => {
11
+ const buf = Buffer.from(`${s}\n`);
12
+ let off = 0;
13
+ while (!closed && off < buf.length) {
14
+ let n = 0;
15
+ try {
16
+ n = writeSync(fd, buf, off);
17
+ }
18
+ catch (err) {
19
+ const code = err.code;
20
+ if (code === 'EPIPE')
21
+ closed = true;
22
+ else if (code !== 'EAGAIN')
23
+ throw err;
24
+ }
25
+ off += n;
26
+ if (n === 0 && !closed)
27
+ Atomics.wait(pause, 0, 0, 5); // the reader is behind (EAGAIN or no progress); wait without spinning
28
+ }
29
+ };
30
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,29 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ const WORKTREE_MARKER = '/.claude/worktrees/';
4
+ /** Repo name for a session cwd, unwrapping `<repo>/.claude/worktrees/<slug>`. */
5
+ export function repoName(cwd) {
6
+ const clean = cwd.replace(/\/+$/, '');
7
+ const at = clean.indexOf(WORKTREE_MARKER);
8
+ if (at >= 0) {
9
+ const worktree = clean.slice(at + WORKTREE_MARKER.length).split('/')[0];
10
+ return { repo: lastSegment(clean.slice(0, at)), worktree };
11
+ }
12
+ return { repo: lastSegment(clean) };
13
+ }
14
+ function lastSegment(p) {
15
+ const parts = p.split('/').filter(Boolean);
16
+ return parts[parts.length - 1] ?? p;
17
+ }
18
+ /** Path of a session transcript: projects/<cwd with non-alphanumerics as '-'>/<id>.jsonl. */
19
+ export function transcriptPath(projectsDir, cwd, sessionId) {
20
+ return join(projectsDir, cwd.replace(/[^A-Za-z0-9]/g, '-'), `${sessionId}.jsonl`);
21
+ }
22
+ /** Default locations, overridable for tests. */
23
+ export const defaultDirs = {
24
+ sessions: join(homedir(), '.claude', 'sessions'),
25
+ projects: join(homedir(), '.claude', 'projects'),
26
+ cache: join(homedir(), '.cache', 'claude-term-tabs', 'digests.json'),
27
+ config: join(homedir(), '.config', 'claude-term-tabs', 'config.json'),
28
+ snapshots: join(homedir(), '.local', 'state', 'claude-term-tabs', 'snapshots')
29
+ };
@@ -0,0 +1,167 @@
1
+ import { hasHandoff, latestAssistantAfterPrompt } from "../handoff.js";
2
+ import { cleanField, formatAge } from "../picker.js";
3
+ import { pruneSnapshots, saveSnapshot, snapshotFrom } from "../snapshot.js";
4
+ import { classify, STRAGGLER_STATES } from "./state.js";
5
+ /** How long `/exit` gets before a session is marked exit-failed. */
6
+ export const EXIT_GRACE_MS = 10_000;
7
+ const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
8
+ const EXIT_COMMAND = '/exit';
9
+ const PROMPT_START_CHARS = 60;
10
+ const TYPEABLE = new Set(['idle', 'busy']);
11
+ const LABELS = {
12
+ done: 'done',
13
+ closing: 'closing',
14
+ closed: 'closed',
15
+ 'exit-failed': 'exit failed',
16
+ busy: 'busy',
17
+ 'may-need-you': 'may need you',
18
+ 'waiting-on-you': 'waiting on you',
19
+ 'idle-no-handoff': 'idle, no HANDOFF',
20
+ 'in-shell-mode': 'in shell mode',
21
+ 'cant-reach': "can't reach",
22
+ exited: 'exited'
23
+ };
24
+ /** Snapshot every live session, then type the stop prompt into each one that can take it. */
25
+ export function startPause(deps, opts) {
26
+ const now = deps.now();
27
+ const cands = deps.collect();
28
+ pruneSnapshots(deps.snapshotDir, now, SNAPSHOT_MAX_AGE_MS);
29
+ const snapshot = snapshotFrom(cands, deps.adapter?.name, now);
30
+ deps.onSnapshot?.(saveSnapshot(deps.snapshotDir, snapshot));
31
+ const tracked = cands.map(cand => {
32
+ const typeable = TYPEABLE.has(cand.status);
33
+ const sent = typeable && sendStop(cand, deps.adapter, deps.config.stopPrompt, deps.warn, deps.inFront);
34
+ const size = safeSize(deps, deps.transcriptOf(cand));
35
+ const classified = classify({ alive: true, status: cand.status, hasPane: canType(cand, deps.adapter), handoff: false, msSinceGrowth: 0 });
36
+ const state = typeable && !sent ? 'cant-reach' : classified;
37
+ return { cand, state, since: now, lastSize: size, lastGrowth: now };
38
+ });
39
+ return { startedAt: now, snapshot, tracked, autoClose: opts.autoClose };
40
+ }
41
+ /** Advance every session one step: refresh facts, classify, and run auto-close. Returns a new run. */
42
+ export function tickPause(run, deps) {
43
+ const now = deps.now();
44
+ const live = new Map(deps.collect().map(c => [c.digest.sessionId, c]));
45
+ const tracked = run.tracked.map(t => step(t, live.get(t.cand.digest.sessionId), run, deps, now));
46
+ const newlyDone = tracked.filter((t, i) => isDone(t.state) && !isDone(run.tracked[i].state));
47
+ if (newlyDone.length === 0)
48
+ return { ...run, tracked };
49
+ const doneIds = new Set(tracked.filter(t => isDone(t.state)).map(t => t.cand.digest.sessionId));
50
+ const snapshot = { ...run.snapshot, sessions: run.snapshot.sessions.map(s => ({ ...s, handoffWritten: doneIds.has(s.sessionId) })) };
51
+ deps.onSnapshot?.(saveSnapshot(deps.snapshotDir, snapshot));
52
+ return { ...run, snapshot, tracked };
53
+ }
54
+ /** Type the stop prompt into one session. Refuses (and warns) unless it is idle or busy, reachable, and Claude is in front. */
55
+ export function sendStop(cand, adapter, prompt, warn, inFront) {
56
+ const name = cand.digest.title ?? cand.digest.sessionId;
57
+ if (cand.status === 'shell') {
58
+ warn(`claude-term-tabs: ${name} is in shell mode; not typing into it`);
59
+ return false;
60
+ }
61
+ if (!TYPEABLE.has(cand.status)) {
62
+ warn(`claude-term-tabs: ${name} is ${cand.status === 'waiting' ? 'waiting on a prompt' : `in status ${cand.status}`}; not typing into it`);
63
+ return false;
64
+ }
65
+ if (adapter === undefined || cand.pane === undefined) {
66
+ warn(`claude-term-tabs: ${name} is not in a supported terminal pane`);
67
+ return false;
68
+ }
69
+ if (inFront(cand) !== 'claude') {
70
+ warn(`claude-term-tabs: Claude is not in front of the tab for ${name}; not typing into it`);
71
+ return false;
72
+ }
73
+ try {
74
+ adapter.sendText(cand.pane, prompt);
75
+ return true;
76
+ }
77
+ catch (err) {
78
+ warn(`claude-term-tabs: could not type into ${name}: ${err.message}`);
79
+ return false;
80
+ }
81
+ }
82
+ /** Picker-style lines for sessions still in a straggler state. */
83
+ export function stragglerLines(run, now) {
84
+ return run.tracked.filter(t => STRAGGLER_STATES.has(t.state)).map(t => {
85
+ const d = t.cand.digest;
86
+ const fields = [d.title ?? '(untitled)', d.repo, LABELS[t.state], formatAge(t.since, now), t.cand.pane !== undefined ? `tab ${t.cand.pane.tabIndex}` : undefined];
87
+ return `${d.sessionId}\t${fields.filter((f) => f !== undefined).map(cleanField).join(' · ')}`;
88
+ });
89
+ }
90
+ /** Final report and exit code: 0 only when every session finished. */
91
+ export function pauseSummary(run) {
92
+ const total = run.tracked.length;
93
+ const done = run.tracked.filter(t => isDone(t.state)).length;
94
+ const closed = run.tracked.filter(t => t.state === 'closed').length;
95
+ const short = (t) => [t.cand.digest.title ?? t.cand.digest.sessionId, t.cand.digest.repo, LABELS[t.state]].map(cleanField).join(' · ');
96
+ const left = run.tracked.filter(t => STRAGGLER_STATES.has(t.state) && t.state !== 'exit-failed').map(short);
97
+ const unclosed = run.tracked.filter(t => t.state === 'exit-failed').map(short);
98
+ const head = `${done} of ${total} sessions wrote a HANDOFF${run.autoClose ? `, ${closed} closed` : ''}.`;
99
+ const failedLines = unclosed.length > 0 ? [`${unclosed.length} sessions could not be closed; close them yourself:`, ...unclosed.map(l => ` - ${l}`)] : [];
100
+ if (left.length === 0)
101
+ return { text: [head, ...failedLines, 'Safe to restart.'].join('\n'), exitCode: 0 };
102
+ return {
103
+ text: [head, ...failedLines, ...left.map(l => ` - ${l}`), `${left.length} sessions have no HANDOFF; they are still recorded.`].join('\n'),
104
+ exitCode: 1
105
+ };
106
+ }
107
+ function step(t, cur, run, deps, now) {
108
+ if (t.state === 'closed' || t.state === 'exit-failed')
109
+ return t;
110
+ if (t.state === 'closing')
111
+ return stepClosing(t, deps, now);
112
+ const cand = cur ?? t.cand;
113
+ const path = deps.transcriptOf(cand);
114
+ const size = safeSize(deps, path);
115
+ const lastGrowth = size !== t.lastSize ? now : t.lastGrowth;
116
+ const alive = cur !== undefined;
117
+ const handoff = hasHandoff(latestAssistantAfterPrompt(path, promptStart(deps.config.stopPrompt), run.startedAt));
118
+ const state = classify({ alive, status: cand.status, hasPane: canType(cand, deps.adapter), handoff, msSinceGrowth: now - lastGrowth });
119
+ const next = { ...t, cand, state, since: state === t.state ? t.since : now, lastSize: size, lastGrowth };
120
+ if (state !== 'done' || !run.autoClose || !alive || cand.status !== 'idle' || !canType(cand, deps.adapter) || deps.inFront(cand) !== 'claude')
121
+ return next;
122
+ try {
123
+ deps.adapter?.sendText(cand.pane, EXIT_COMMAND);
124
+ return { ...next, state: 'closing', since: now, closeRequestedAt: now };
125
+ }
126
+ catch (err) {
127
+ deps.warn(`claude-term-tabs: could not exit ${cand.digest.title ?? cand.digest.sessionId}: ${err.message}`);
128
+ return next;
129
+ }
130
+ }
131
+ function stepClosing(t, deps, now) {
132
+ const pid = t.cand.pid;
133
+ if (pid !== undefined && !deps.isAlive(pid)) {
134
+ if (deps.inFront(t.cand) !== 'shell') {
135
+ deps.warn(`claude-term-tabs: ${t.cand.digest.title ?? t.cand.digest.sessionId} exited, but something other than its shell is in front; leaving the tab open`);
136
+ return { ...t, state: 'done', since: now };
137
+ }
138
+ try {
139
+ if (t.cand.pane !== undefined)
140
+ deps.adapter?.closePane(t.cand.pane);
141
+ }
142
+ catch (err) {
143
+ deps.warn(`claude-term-tabs: could not close the tab for ${t.cand.digest.title ?? t.cand.digest.sessionId}: ${err.message}`);
144
+ }
145
+ return { ...t, state: 'closed', since: now };
146
+ }
147
+ if (now - (t.closeRequestedAt ?? now) > EXIT_GRACE_MS)
148
+ return { ...t, state: 'exit-failed', since: now };
149
+ return t;
150
+ }
151
+ function isDone(s) {
152
+ return s === 'done' || s === 'closing' || s === 'closed' || s === 'exit-failed';
153
+ }
154
+ function promptStart(prompt) {
155
+ return prompt.trimStart().split('\n')[0].slice(0, PROMPT_START_CHARS);
156
+ }
157
+ function canType(c, adapter) {
158
+ return adapter !== undefined && c.pane !== undefined;
159
+ }
160
+ function safeSize(deps, path) {
161
+ try {
162
+ return deps.sizeOf(path);
163
+ }
164
+ catch {
165
+ return -1;
166
+ }
167
+ }
@@ -0,0 +1,24 @@
1
+ /** Busy this long with no transcript growth reads as "may need you" (likely a permission prompt). */
2
+ export const MAY_NEED_YOU_AFTER_MS = 120_000;
3
+ /** States that keep a session in the straggler list. */
4
+ export const STRAGGLER_STATES = new Set([
5
+ 'busy', 'may-need-you', 'waiting-on-you', 'idle-no-handoff', 'in-shell-mode', 'cant-reach', 'exited', 'exit-failed'
6
+ ]);
7
+ /** States that no longer change; a pause can stop waiting once every session is in one. */
8
+ export const FINAL_STATES = new Set(['done', 'closed', 'exited', 'exit-failed']);
9
+ /** Classify a session from its facts. Never returns closing, closed or exit-failed; the run sets those. */
10
+ export function classify(i) {
11
+ if (i.handoff && (!i.alive || i.status !== 'busy'))
12
+ return 'done';
13
+ if (!i.alive)
14
+ return 'exited';
15
+ if (!i.hasPane)
16
+ return 'cant-reach';
17
+ if (i.status === 'shell')
18
+ return 'in-shell-mode';
19
+ if (i.status === 'waiting')
20
+ return 'waiting-on-you';
21
+ if (i.status === 'busy')
22
+ return i.msSinceGrowth > MAY_NEED_YOU_AFTER_MS ? 'may-need-you' : 'busy';
23
+ return 'idle-no-handoff';
24
+ }