tlc-claude-code 2.1.0 → 2.2.0

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,101 @@
1
+ /**
2
+ * Tmux Session Manager
3
+ * Manages tmux sessions with panes for agent visibility.
4
+ */
5
+
6
+ /**
7
+ * Sanitizes a session name by stripping special characters and replacing spaces with hyphens.
8
+ * @param {string} name - Raw session name
9
+ * @returns {string} Sanitized session name
10
+ */
11
+ function sanitizeName(name) {
12
+ return name
13
+ .replace(/\s+/g, '-')
14
+ .replace(/[^a-zA-Z0-9\-_]/g, '');
15
+ }
16
+
17
+ /**
18
+ * Checks whether tmux is available on the system.
19
+ * @param {{ exec: Function }} deps - Injected exec function
20
+ * @returns {boolean} True if tmux is found, false otherwise
21
+ */
22
+ export function isAvailable({ exec }) {
23
+ try {
24
+ exec('which tmux');
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Creates a new detached tmux session with the given name.
33
+ * @param {string} name - Session name (will be sanitized)
34
+ * @param {{ exec: Function }} deps - Injected exec function
35
+ */
36
+ export function createSession(name, { exec }) {
37
+ const safe = sanitizeName(name);
38
+ exec(`tmux new-session -d -s ${safe}`);
39
+ }
40
+
41
+ /**
42
+ * Adds a pane to an existing tmux session and runs a command in it.
43
+ * @param {string} session - Session name
44
+ * @param {string} paneName - Logical pane name (unused by tmux directly)
45
+ * @param {string} command - Command to send to the pane
46
+ * @param {{ exec: Function, paneCount?: number, paneIndex?: number }} deps - Injected deps
47
+ */
48
+ export function addPane(session, paneName, command, { exec, paneCount = 2, paneIndex = 1 }) {
49
+ // For 4-pane grid: alternate horizontal/vertical splits based on pane index
50
+ // For 2 panes: vertical split (side by side)
51
+ // Default: vertical split
52
+ let splitFlag;
53
+ if (paneCount === 4) {
54
+ // paneIndex 1,3 => horizontal split; paneIndex 2,4 => vertical split
55
+ splitFlag = paneIndex % 2 === 1 ? '-h' : '-v';
56
+ } else {
57
+ // 2 panes: vertical split (splits current pane side-by-side)
58
+ splitFlag = '-h';
59
+ }
60
+
61
+ exec(`tmux split-window ${splitFlag} -t ${session}`);
62
+ // Use single quotes to avoid shell re-tokenizing nested double quotes in the command
63
+ exec(`tmux send-keys -t ${session} '${command.replace(/'/g, "'\\''")}' Enter`);
64
+ }
65
+
66
+ /**
67
+ * Captures the current content of a pane and returns it as a string.
68
+ * @param {string} session - Session name
69
+ * @param {number} paneIndex - Zero-based pane index
70
+ * @param {{ exec: Function }} deps - Injected exec function
71
+ * @returns {string} Captured pane content
72
+ */
73
+ export function capturePane(session, paneIndex, { exec }) {
74
+ // Use .N suffix to target pane N within the session's first window
75
+ return exec(`tmux capture-pane -t ${session}:.${paneIndex} -p`);
76
+ }
77
+
78
+ /**
79
+ * Kills a tmux session by name.
80
+ * @param {string} name - Session name
81
+ * @param {{ exec: Function }} deps - Injected exec function
82
+ */
83
+ export function killSession(name, { exec }) {
84
+ const safe = sanitizeName(name);
85
+ exec(`tmux kill-session -t ${safe}`);
86
+ }
87
+
88
+ /**
89
+ * Builds a layout descriptor based on the number of agents.
90
+ * @param {number} agentCount - Number of agents/panes
91
+ * @returns {{ type: string }} Layout descriptor
92
+ */
93
+ export function buildLayout(agentCount) {
94
+ if (agentCount === 2) {
95
+ return { type: 'vertical' };
96
+ }
97
+ if (agentCount <= 4) {
98
+ return { type: 'grid' };
99
+ }
100
+ return { type: 'tiled' };
101
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ createSession,
4
+ addPane,
5
+ capturePane,
6
+ killSession,
7
+ isAvailable,
8
+ buildLayout,
9
+ } from './tmux-manager.js';
10
+
11
+ describe('tmux-manager', () => {
12
+ let exec;
13
+
14
+ beforeEach(() => {
15
+ exec = vi.fn(() => '');
16
+ });
17
+
18
+ describe('isAvailable', () => {
19
+ it('returns true when tmux is installed', () => {
20
+ exec.mockReturnValue('/usr/bin/tmux');
21
+ expect(isAvailable({ exec })).toBe(true);
22
+ });
23
+
24
+ it('returns false when tmux is missing', () => {
25
+ exec.mockImplementation(() => { throw new Error('not found'); });
26
+ expect(isAvailable({ exec })).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe('createSession', () => {
31
+ it('generates correct tmux new-session command', () => {
32
+ exec.mockReturnValue('');
33
+ createSession('tlc-build-42', { exec });
34
+ const cmd = exec.mock.calls[0][0];
35
+ expect(cmd).toContain('tmux new-session');
36
+ expect(cmd).toContain('tlc-build-42');
37
+ });
38
+
39
+ it('sanitizes session names', () => {
40
+ exec.mockReturnValue('');
41
+ createSession('bad session/name!', { exec });
42
+ const cmd = exec.mock.calls[0][0];
43
+ expect(cmd).not.toContain('/');
44
+ expect(cmd).not.toContain('!');
45
+ });
46
+ });
47
+
48
+ describe('addPane', () => {
49
+ it('splits correctly for 2 panes (vertical)', () => {
50
+ exec.mockReturnValue('');
51
+ addPane('sess', 'agent-1', 'echo hello', { exec, paneCount: 2 });
52
+ const cmd = exec.mock.calls[0][0];
53
+ expect(cmd).toContain('split-window');
54
+ });
55
+
56
+ it('splits correctly for 4 panes (2x2 grid)', () => {
57
+ exec.mockReturnValue('');
58
+ addPane('sess', 'agent-3', 'echo test', { exec, paneCount: 4, paneIndex: 3 });
59
+ const cmd = exec.mock.calls[0][0];
60
+ expect(cmd).toContain('split-window');
61
+ });
62
+
63
+ it('runs command in the new pane', () => {
64
+ exec.mockReturnValue('');
65
+ addPane('sess', 'agent-1', 'codex exec "do stuff"', { exec, paneCount: 2 });
66
+ const cmds = exec.mock.calls.map(c => c[0]);
67
+ expect(cmds.some(c => c.includes('codex exec'))).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe('capturePane', () => {
72
+ it('returns pane content via tmux capture-pane', () => {
73
+ exec.mockReturnValue('Line 1\nLine 2\nLine 3');
74
+ const content = capturePane('sess', 0, { exec });
75
+ expect(content).toContain('Line 1');
76
+ expect(content).toContain('Line 3');
77
+ const cmd = exec.mock.calls[0][0];
78
+ expect(cmd).toContain('capture-pane');
79
+ expect(cmd).toContain(':.'); // pane target uses .N syntax
80
+ });
81
+ });
82
+
83
+ describe('killSession', () => {
84
+ it('runs tmux kill-session', () => {
85
+ exec.mockReturnValue('');
86
+ killSession('tlc-build-42', { exec });
87
+ const cmd = exec.mock.calls[0][0];
88
+ expect(cmd).toContain('kill-session');
89
+ expect(cmd).toContain('tlc-build-42');
90
+ });
91
+ });
92
+
93
+ describe('buildLayout', () => {
94
+ it('returns vertical for 2 agents', () => {
95
+ const layout = buildLayout(2);
96
+ expect(layout.type).toBe('vertical');
97
+ });
98
+
99
+ it('returns grid for 4 agents', () => {
100
+ const layout = buildLayout(4);
101
+ expect(layout.type).toBe('grid');
102
+ });
103
+
104
+ it('returns tiled for >4 agents', () => {
105
+ const layout = buildLayout(6);
106
+ expect(layout.type).toBe('tiled');
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Worktree Manager
3
+ * Creates and manages git worktrees for parallel agent work.
4
+ */
5
+
6
+ /**
7
+ * Sanitize a worktree name: spaces become hyphens, all other non-alphanumeric
8
+ * non-hyphen characters are stripped, then multiple hyphens are collapsed.
9
+ * @param {string} name
10
+ * @returns {string}
11
+ */
12
+ export function sanitizeName(name) {
13
+ return name
14
+ .replace(/ /g, '-')
15
+ .replace(/[^a-zA-Z0-9-]/g, '')
16
+ .replace(/-{2,}/g, '-');
17
+ }
18
+
19
+ /**
20
+ * Create a git worktree for the given name.
21
+ * @param {string} name - Worktree name (will be sanitized)
22
+ * @param {{ exec: Function, maxConcurrent?: number }} options
23
+ * @returns {{ name: string, branch: string, path: string }}
24
+ */
25
+ export function createWorktree(name, { exec, maxConcurrent } = {}) {
26
+ const safe = sanitizeName(name);
27
+ const limit = maxConcurrent != null ? maxConcurrent : 4;
28
+
29
+ // Only check the concurrent limit when explicitly requested (maxConcurrent passed)
30
+ if (maxConcurrent != null) {
31
+ const listOutput = exec('git worktree list');
32
+ // Exclude the primary checkout (first line) — only count auxiliary worktrees
33
+ const currentCount = listOutput
34
+ .split('\n')
35
+ .filter((line) => line.trim() !== '').length - 1;
36
+
37
+ if (currentCount >= limit) {
38
+ throw new Error(`Max concurrent worktrees (${limit}) reached`);
39
+ }
40
+ }
41
+
42
+ const branch = `worktree-${safe}`;
43
+ const path = `.claude/worktrees/${safe}`;
44
+
45
+ exec(`git worktree add -b ${branch} ${path}`);
46
+
47
+ return { name: safe, branch, path };
48
+ }
49
+
50
+ /**
51
+ * List active worktrees under .claude/worktrees/.
52
+ * @param {{ exec: Function }} options
53
+ * @returns {Array<{ name: string, branch: string, path: string }>}
54
+ */
55
+ export function listWorktrees({ exec } = {}) {
56
+ const output = exec('git worktree list');
57
+ return output
58
+ .split('\n')
59
+ .filter((line) => line.includes('.claude/worktrees/'))
60
+ .map((line) => {
61
+ // Format: /repo/.claude/worktrees/task-1 abc1234 [worktree-task-1]
62
+ const parts = line.trim().split(/\s+/);
63
+ const worktreePath = parts[0];
64
+ const branchMatch = line.match(/\[([^\]]+)\]/);
65
+ const branch = branchMatch ? branchMatch[1] : '';
66
+ const namePart = worktreePath.split('.claude/worktrees/')[1];
67
+ return { name: namePart, branch, path: worktreePath };
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Merge a worktree branch into targetBranch, then remove the worktree.
73
+ * On conflict, returns { merged: false, conflict: true } without removing.
74
+ * @param {string} name - Worktree name (already sanitized)
75
+ * @param {string} targetBranch
76
+ * @param {{ exec: Function }} options
77
+ * @returns {{ merged: boolean, conflict?: boolean }}
78
+ */
79
+ export function mergeWorktree(name, targetBranch, { exec } = {}) {
80
+ const safe = sanitizeName(name);
81
+ const branch = `worktree-${safe}`;
82
+ const path = `.claude/worktrees/${safe}`;
83
+
84
+ try {
85
+ exec(`git checkout ${targetBranch}`);
86
+ exec(`git merge ${branch}`);
87
+ } catch (_err) {
88
+ return { merged: false, conflict: true };
89
+ }
90
+
91
+ exec(`git worktree remove ${path}`);
92
+ exec(`git branch -D ${branch}`);
93
+
94
+ return { merged: true };
95
+ }
96
+
97
+ /**
98
+ * Forcefully remove a worktree directory and its tracking branch.
99
+ * @param {string} name - Worktree name (already sanitized)
100
+ * @param {{ exec: Function }} options
101
+ */
102
+ export function removeWorktree(name, { exec } = {}) {
103
+ const safe = sanitizeName(name);
104
+ const branch = `worktree-${safe}`;
105
+ const path = `.claude/worktrees/${safe}`;
106
+
107
+ exec(`git worktree remove --force ${path}`);
108
+ exec(`git branch -D ${branch}`);
109
+ }
110
+
111
+ /**
112
+ * Remove worktrees whose last commit is older than maxAge milliseconds.
113
+ * @param {{ exec: Function, maxAge?: number }} options
114
+ * @returns {string[]} Names of removed worktrees
115
+ */
116
+ export function cleanupStale({ exec, maxAge = 2 * 3600_000 } = {}) {
117
+ const worktrees = listWorktrees({ exec });
118
+ const removed = [];
119
+ const now = Date.now();
120
+
121
+ for (const wt of worktrees) {
122
+ const lastCommitStr = exec(`git log -1 --format=%cI ${wt.branch}`).trim();
123
+ const lastCommitTime = new Date(lastCommitStr).getTime();
124
+
125
+ if (now - lastCommitTime > maxAge) {
126
+ removeWorktree(wt.name, { exec });
127
+ removed.push(wt.name);
128
+ }
129
+ }
130
+
131
+ return removed;
132
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ createWorktree,
4
+ listWorktrees,
5
+ mergeWorktree,
6
+ removeWorktree,
7
+ cleanupStale,
8
+ sanitizeName,
9
+ } from './worktree-manager.js';
10
+
11
+ describe('worktree-manager', () => {
12
+ let exec;
13
+
14
+ beforeEach(() => {
15
+ exec = vi.fn(() => '');
16
+ });
17
+
18
+ describe('sanitizeName', () => {
19
+ it('passes through alphanumeric and hyphens', () => {
20
+ expect(sanitizeName('phase-42-task-1')).toBe('phase-42-task-1');
21
+ });
22
+
23
+ it('strips special characters', () => {
24
+ expect(sanitizeName('my task/name@v2!')).toBe('my-tasknamev2');
25
+ });
26
+
27
+ it('collapses multiple hyphens', () => {
28
+ expect(sanitizeName('a--b---c')).toBe('a-b-c');
29
+ });
30
+ });
31
+
32
+ describe('createWorktree', () => {
33
+ it('creates directory and branch', () => {
34
+ exec.mockReturnValue('');
35
+ const result = createWorktree('phase-42-task-1', { exec });
36
+ expect(result.name).toBe('phase-42-task-1');
37
+ expect(result.branch).toBe('worktree-phase-42-task-1');
38
+ expect(result.path).toContain('.claude/worktrees/phase-42-task-1');
39
+ expect(exec).toHaveBeenCalled();
40
+ const cmd = exec.mock.calls[0][0];
41
+ expect(cmd).toContain('git worktree add');
42
+ });
43
+
44
+ it('rejects when at max concurrent limit', () => {
45
+ // 5 lines = 1 primary + 4 auxiliary worktrees → at limit of 4
46
+ exec.mockReturnValue('/repo abc [main]\nworktree-a\nworktree-b\nworktree-c\nworktree-d\n');
47
+ expect(() => createWorktree('task-5', { exec, maxConcurrent: 4 }))
48
+ .toThrow(/max/i);
49
+ });
50
+
51
+ it('sanitizes names', () => {
52
+ exec.mockReturnValue('');
53
+ const result = createWorktree('bad name/here!', { exec });
54
+ expect(result.name).toBe('bad-namehere');
55
+ });
56
+ });
57
+
58
+ describe('listWorktrees', () => {
59
+ it('returns active worktrees with branch and path', () => {
60
+ exec.mockReturnValue(
61
+ '/repo/.claude/worktrees/task-1 abc1234 [worktree-task-1]\n' +
62
+ '/repo/.claude/worktrees/task-2 def5678 [worktree-task-2]\n'
63
+ );
64
+ const list = listWorktrees({ exec });
65
+ expect(list).toHaveLength(2);
66
+ expect(list[0].name).toBe('task-1');
67
+ expect(list[0].branch).toBe('worktree-task-1');
68
+ expect(list[0].path).toContain('task-1');
69
+ });
70
+
71
+ it('returns empty array when no worktrees', () => {
72
+ exec.mockReturnValue('/repo abc1234 [main]\n');
73
+ const list = listWorktrees({ exec });
74
+ expect(list).toHaveLength(0);
75
+ });
76
+ });
77
+
78
+ describe('mergeWorktree', () => {
79
+ it('merges branch and cleans up', () => {
80
+ exec.mockReturnValue('');
81
+ const result = mergeWorktree('task-1', 'main', { exec });
82
+ expect(result.merged).toBe(true);
83
+ const cmds = exec.mock.calls.map(c => c[0]);
84
+ expect(cmds.some(c => c.includes('git merge'))).toBe(true);
85
+ expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
86
+ });
87
+
88
+ it('reports conflict without destroying worktree', () => {
89
+ exec.mockImplementation((cmd) => {
90
+ if (cmd.includes('git merge')) throw new Error('CONFLICT');
91
+ return '';
92
+ });
93
+ const result = mergeWorktree('task-1', 'main', { exec });
94
+ expect(result.merged).toBe(false);
95
+ expect(result.conflict).toBe(true);
96
+ // Should NOT have called worktree remove
97
+ const cmds = exec.mock.calls.map(c => c[0]);
98
+ expect(cmds.some(c => c.includes('git worktree remove'))).toBe(false);
99
+ });
100
+ });
101
+
102
+ describe('removeWorktree', () => {
103
+ it('removes both directory and branch', () => {
104
+ exec.mockReturnValue('');
105
+ removeWorktree('task-1', { exec });
106
+ const cmds = exec.mock.calls.map(c => c[0]);
107
+ expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
108
+ expect(cmds.some(c => c.includes('git branch -D'))).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe('cleanupStale', () => {
113
+ it('removes only worktrees past timeout', () => {
114
+ const oldDate = new Date(Date.now() - 3 * 3600_000).toISOString(); // 3h ago
115
+ const freshDate = new Date(Date.now() - 30 * 60_000).toISOString(); // 30min ago
116
+ exec.mockImplementation((cmd) => {
117
+ if (cmd.includes('worktree list')) {
118
+ return '/repo/.claude/worktrees/old-task abc [worktree-old-task]\n/repo/.claude/worktrees/new-task def [worktree-new-task]\n';
119
+ }
120
+ if (cmd.includes('log') && cmd.includes('old-task')) return oldDate;
121
+ if (cmd.includes('log') && cmd.includes('new-task')) return freshDate;
122
+ return '';
123
+ });
124
+ const removed = cleanupStale({ exec, maxAge: 2 * 3600_000 });
125
+ expect(removed).toContain('old-task');
126
+ expect(removed).not.toContain('new-task');
127
+ });
128
+ });
129
+ });