@generativereality/cctabs 0.4.5 → 0.4.6
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/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +381 -31
- package/package.json +5 -2
- package/skills/cctabs/SKILL.md +8 -10
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctabs",
|
|
3
3
|
"description": "Claude Code tab manager. Terminal tabs as the UI, no tmux. Claude can orchestrate its own sibling sessions.",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.6",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "generativereality",
|
|
7
7
|
"url": "https://cctabs.com"
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { consola } from "consola";
|
|
|
11
11
|
import * as p from "@clack/prompts";
|
|
12
12
|
//#region package.json
|
|
13
13
|
var name = "@generativereality/cctabs";
|
|
14
|
-
var version = "0.4.
|
|
14
|
+
var version = "0.4.6";
|
|
15
15
|
var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
|
|
16
16
|
var package_default = {
|
|
17
17
|
name,
|
|
@@ -28,8 +28,10 @@ var package_default = {
|
|
|
28
28
|
"dev": "bun run ./src/index.ts",
|
|
29
29
|
"build": "tsdown",
|
|
30
30
|
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"test:watch": "bun test --watch",
|
|
31
33
|
"lint": "eslint src/",
|
|
32
|
-
"check": "npm run typecheck && npm run build",
|
|
34
|
+
"check": "npm run typecheck && npm run test && npm run build",
|
|
33
35
|
"release": "bumpp && npm publish",
|
|
34
36
|
"sync-plugin": "bash scripts/sync-plugin.sh",
|
|
35
37
|
"build:tabby-plugin": "cd tabby-plugin && npm install && npm run build",
|
|
@@ -63,6 +65,7 @@ var package_default = {
|
|
|
63
65
|
"update-notifier": "^7.3.1"
|
|
64
66
|
},
|
|
65
67
|
devDependencies: {
|
|
68
|
+
"@types/bun": "^1.3.14",
|
|
66
69
|
"@types/node": "^22.0.0",
|
|
67
70
|
"@types/update-notifier": "^6.0.8",
|
|
68
71
|
"bumpp": "^9.11.1",
|
|
@@ -957,6 +960,97 @@ function findSessionsByName(dir, name) {
|
|
|
957
960
|
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
958
961
|
}
|
|
959
962
|
/**
|
|
963
|
+
* Per-project-dir cache of `customTitle → newest session`. Built once per
|
|
964
|
+
* project directory and reused, so callers that resolve many tabs (e.g.
|
|
965
|
+
* `cctabs sessions --json` over 40+ tabs sharing one repo's project dir)
|
|
966
|
+
* scan each directory a single time instead of once per tab.
|
|
967
|
+
*
|
|
968
|
+
* Cleared implicitly per process — cctabs commands are one-shot, so a stale
|
|
969
|
+
* cache is never a concern within a single invocation.
|
|
970
|
+
*/
|
|
971
|
+
const titleIndexCache = /* @__PURE__ */ new Map();
|
|
972
|
+
function buildTitleIndex(projectDir) {
|
|
973
|
+
const cached = titleIndexCache.get(projectDir);
|
|
974
|
+
if (cached) return cached;
|
|
975
|
+
const index = /* @__PURE__ */ new Map();
|
|
976
|
+
if (!existsSync(projectDir)) {
|
|
977
|
+
titleIndexCache.set(projectDir, index);
|
|
978
|
+
return index;
|
|
979
|
+
}
|
|
980
|
+
for (const f of readdirSync(projectDir)) {
|
|
981
|
+
if (extname(f) !== ".jsonl") continue;
|
|
982
|
+
const full = join(projectDir, f);
|
|
983
|
+
let title = "";
|
|
984
|
+
let cwd = "";
|
|
985
|
+
try {
|
|
986
|
+
const content = readFileSync(full, "utf-8");
|
|
987
|
+
for (const line of content.split("\n")) {
|
|
988
|
+
const hasTitle = line.includes("\"customTitle\"");
|
|
989
|
+
const hasCwd = !cwd && line.includes("\"cwd\"");
|
|
990
|
+
if (!hasTitle && !hasCwd) continue;
|
|
991
|
+
try {
|
|
992
|
+
const e = JSON.parse(line);
|
|
993
|
+
if (e.customTitle !== void 0) title = e.customTitle;
|
|
994
|
+
if (!cwd && typeof e.cwd === "string") cwd = e.cwd;
|
|
995
|
+
} catch {}
|
|
996
|
+
}
|
|
997
|
+
} catch {
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (!title) continue;
|
|
1001
|
+
let mtime = 0;
|
|
1002
|
+
try {
|
|
1003
|
+
mtime = statSync(full).mtimeMs;
|
|
1004
|
+
} catch {}
|
|
1005
|
+
const id = basename(f, ".jsonl");
|
|
1006
|
+
const prev = index.get(title);
|
|
1007
|
+
if (!prev || mtime > prev.mtime) index.set(title, {
|
|
1008
|
+
id,
|
|
1009
|
+
cwd,
|
|
1010
|
+
mtime
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
titleIndexCache.set(projectDir, index);
|
|
1014
|
+
return index;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Resolve the Claude session a *tab* is running, given the tab's shell cwd and
|
|
1018
|
+
* its name. Crucially worktree-aware: a tab opened with `--worktree <name>`
|
|
1019
|
+
* keeps its shell cwd at the repo root, but Claude runs inside
|
|
1020
|
+
* `<repo>/.claude/worktrees/<name>` — so its session lives under that
|
|
1021
|
+
* worktree's project slug, NOT the repo-root slug. Resolving by the repo-root
|
|
1022
|
+
* cwd alone (as a naive name lookup does) finds an older same-named session in
|
|
1023
|
+
* the repo root, or nothing — the exact bug that made restore resume the wrong
|
|
1024
|
+
* conversation for worktree tabs.
|
|
1025
|
+
*
|
|
1026
|
+
* Returns the matched session id AND the directory Claude must be launched from
|
|
1027
|
+
* to resume it (the worktree path for worktree tabs), or null if none matches.
|
|
1028
|
+
*/
|
|
1029
|
+
function resolveTabSession(cwd, name, projectsRoot = join(homedir(), ".claude", "projects")) {
|
|
1030
|
+
const namedWtPath = join(cwd, ".claude", "worktrees", name);
|
|
1031
|
+
const namedHit = buildTitleIndex(join(projectsRoot, pathToProjectSlug(namedWtPath))).get(name);
|
|
1032
|
+
if (namedHit) return {
|
|
1033
|
+
id: namedHit.id,
|
|
1034
|
+
dir: namedHit.cwd || namedWtPath
|
|
1035
|
+
};
|
|
1036
|
+
const candidates = [join(projectsRoot, pathToProjectSlug(cwd))];
|
|
1037
|
+
const worktreesDir = join(cwd, ".claude", "worktrees");
|
|
1038
|
+
if (existsSync(worktreesDir)) for (const entry of readdirSync(worktreesDir)) candidates.push(join(projectsRoot, pathToProjectSlug(join(worktreesDir, entry))));
|
|
1039
|
+
let best = null;
|
|
1040
|
+
for (const projectDir of candidates) {
|
|
1041
|
+
const hit = buildTitleIndex(projectDir).get(name);
|
|
1042
|
+
if (hit && (!best || hit.mtime > best.mtime)) best = {
|
|
1043
|
+
id: hit.id,
|
|
1044
|
+
dir: hit.cwd || cwd,
|
|
1045
|
+
mtime: hit.mtime
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
return best ? {
|
|
1049
|
+
id: best.id,
|
|
1050
|
+
dir: best.dir
|
|
1051
|
+
} : null;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
960
1054
|
* Like findSessionsByName, but searches every project directory under
|
|
961
1055
|
* ~/.claude/projects. Each match carries the cwd recorded in the session.
|
|
962
1056
|
* Used by `cctabs restore` so callers don't have to guess the right dir.
|
|
@@ -1023,6 +1117,48 @@ function findSessionsByNameGlobally(name) {
|
|
|
1023
1117
|
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
1024
1118
|
}
|
|
1025
1119
|
/**
|
|
1120
|
+
* Single-pass scan of every ~/.claude/projects/*\/*.jsonl: for each session,
|
|
1121
|
+
* return its current customTitle (the LAST one in the file — sessions can be
|
|
1122
|
+
* renamed) and its mtime. Used by `cctabs sort` to score tab activity.
|
|
1123
|
+
*
|
|
1124
|
+
* Returns Map<customTitle, latestMtimeMs>: when a title appears in multiple
|
|
1125
|
+
* sessions (forks, restarts), we keep the most recent mtime.
|
|
1126
|
+
*/
|
|
1127
|
+
function buildTitleActivityMap() {
|
|
1128
|
+
const projectsRoot = join(homedir(), ".claude", "projects");
|
|
1129
|
+
const result = /* @__PURE__ */ new Map();
|
|
1130
|
+
if (!existsSync(projectsRoot)) return result;
|
|
1131
|
+
for (const slug of readdirSync(projectsRoot)) {
|
|
1132
|
+
const projectDir = join(projectsRoot, slug);
|
|
1133
|
+
let isDir = false;
|
|
1134
|
+
try {
|
|
1135
|
+
isDir = statSync(projectDir).isDirectory();
|
|
1136
|
+
} catch {
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
if (!isDir) continue;
|
|
1140
|
+
for (const f of readdirSync(projectDir)) {
|
|
1141
|
+
if (extname(f) !== ".jsonl") continue;
|
|
1142
|
+
const fullPath = join(projectDir, f);
|
|
1143
|
+
try {
|
|
1144
|
+
const mtime = statSync(fullPath).mtimeMs;
|
|
1145
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
1146
|
+
let currentTitle = "";
|
|
1147
|
+
for (const line of content.split("\n")) {
|
|
1148
|
+
if (!line.trim()) continue;
|
|
1149
|
+
try {
|
|
1150
|
+
const entry = JSON.parse(line);
|
|
1151
|
+
if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
|
|
1152
|
+
} catch {}
|
|
1153
|
+
}
|
|
1154
|
+
if (!currentTitle) continue;
|
|
1155
|
+
if (mtime > (result.get(currentTitle) ?? 0)) result.set(currentTitle, mtime);
|
|
1156
|
+
} catch {}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return result;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1026
1162
|
* List all unique session names (customTitle) in a project directory.
|
|
1027
1163
|
* Used to show available names when a resume lookup fails.
|
|
1028
1164
|
*/
|
|
@@ -1119,15 +1255,19 @@ const sessionsCommand = define({
|
|
|
1119
1255
|
const status = adapter.detectSessionStatus(b.blockid);
|
|
1120
1256
|
const lastLine = adapter.scrollback(b.blockid, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
|
|
1121
1257
|
let sessionId = null;
|
|
1258
|
+
let sessionDir = cwd;
|
|
1122
1259
|
if (cwd) try {
|
|
1123
|
-
const
|
|
1124
|
-
if (
|
|
1260
|
+
const resolved = resolveTabSession(cwd, tabName);
|
|
1261
|
+
if (resolved) {
|
|
1262
|
+
sessionId = resolved.id;
|
|
1263
|
+
sessionDir = resolved.dir;
|
|
1264
|
+
}
|
|
1125
1265
|
} catch {}
|
|
1126
1266
|
wsRow.sessions.push({
|
|
1127
1267
|
block_id: b.blockid,
|
|
1128
1268
|
tab_id: tabId,
|
|
1129
1269
|
name: tabName,
|
|
1130
|
-
cwd,
|
|
1270
|
+
cwd: sessionDir,
|
|
1131
1271
|
current: tabId === currentTab,
|
|
1132
1272
|
status,
|
|
1133
1273
|
last_line: lastLine.slice(0, 200),
|
|
@@ -1328,11 +1468,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
1328
1468
|
* long / multi-line prompt collapses to a "[Pasted text #N +M lines]" chip.
|
|
1329
1469
|
*/
|
|
1330
1470
|
async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
|
|
1471
|
+
const readySignal = /❯|auto mode|for agents|Try ["'“]/;
|
|
1331
1472
|
try {
|
|
1332
|
-
await waitForScrollbackMatch(adapter, blockId,
|
|
1473
|
+
await waitForScrollbackMatch(adapter, blockId, readySignal, "Claude prompt", 45e3);
|
|
1333
1474
|
} catch {
|
|
1334
|
-
|
|
1335
|
-
throw new Error("Claude prompt (❯) never appeared — not sending initial prompt. Check that claude started successfully.");
|
|
1475
|
+
consola.warn("Could not confirm Claude was ready within 45s — sending the prompt anyway (will verify it lands).");
|
|
1336
1476
|
}
|
|
1337
1477
|
if (/trustthisfolder|Yes,?Itrustthis|Isthisaproject/i.test(adapter.scrollback(blockId, 40).replace(/\s+/g, ""))) for (let attempt = 0; attempt < 18; attempt++) {
|
|
1338
1478
|
const screen = adapter.scrollback(blockId, 14).replace(/\s+/g, "");
|
|
@@ -1365,9 +1505,9 @@ async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
|
|
|
1365
1505
|
consola.warn("Initial prompt may not have landed in the input box — switch to the tab and press Enter (re-type if the box is empty).");
|
|
1366
1506
|
return;
|
|
1367
1507
|
}
|
|
1368
|
-
for (let attempt = 0; attempt <
|
|
1508
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
1369
1509
|
await adapter.sendInput(blockId, "\r");
|
|
1370
|
-
await sleep(
|
|
1510
|
+
await sleep(700);
|
|
1371
1511
|
const tail = adapter.scrollback(blockId, 40);
|
|
1372
1512
|
if (/[✻✽✶✳✢]/.test(tail) || /esctointerrupt/i.test(tail.replace(/\s+/g, ""))) return;
|
|
1373
1513
|
}
|
|
@@ -1681,6 +1821,89 @@ function listBackends() {
|
|
|
1681
1821
|
}));
|
|
1682
1822
|
}
|
|
1683
1823
|
//#endregion
|
|
1824
|
+
//#region src/core/worktree.ts
|
|
1825
|
+
function runGit(cwd, args) {
|
|
1826
|
+
return execFileSync("git", [
|
|
1827
|
+
"-C",
|
|
1828
|
+
cwd,
|
|
1829
|
+
...args
|
|
1830
|
+
], {
|
|
1831
|
+
encoding: "utf-8",
|
|
1832
|
+
stdio: [
|
|
1833
|
+
"ignore",
|
|
1834
|
+
"pipe",
|
|
1835
|
+
"pipe"
|
|
1836
|
+
]
|
|
1837
|
+
}).trim();
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Create (or reuse) `<dir>/.claude/worktrees/<name>` anchored at <dir>'s current HEAD.
|
|
1841
|
+
*
|
|
1842
|
+
* Why this exists: `claude --worktree <name>` historically does not always branch
|
|
1843
|
+
* from the current HEAD — with un-pushed local commits it can branch from the
|
|
1844
|
+
* upstream tracking ref instead, silently producing a worktree at a stale
|
|
1845
|
+
* commit. cctabs creates the worktree explicitly so the new tab starts from
|
|
1846
|
+
* exactly the commit the parent session was working on.
|
|
1847
|
+
*/
|
|
1848
|
+
function setupWorktree(dir, name) {
|
|
1849
|
+
const absDir = resolve(dir.replace(/^~/, homedir()));
|
|
1850
|
+
const worktreePath = join(absDir, ".claude", "worktrees", name);
|
|
1851
|
+
const branchName = `worktree-${name}`;
|
|
1852
|
+
try {
|
|
1853
|
+
runGit(absDir, ["rev-parse", "--is-inside-work-tree"]);
|
|
1854
|
+
} catch {
|
|
1855
|
+
throw new Error(`Not a git repository: ${absDir}`);
|
|
1856
|
+
}
|
|
1857
|
+
const parentHeadSha = runGit(absDir, ["rev-parse", "HEAD"]);
|
|
1858
|
+
if (existsSync(worktreePath)) return {
|
|
1859
|
+
worktreePath,
|
|
1860
|
+
branchName,
|
|
1861
|
+
baseSha: runGit(worktreePath, ["rev-parse", "HEAD"]),
|
|
1862
|
+
parentHeadSha,
|
|
1863
|
+
created: false,
|
|
1864
|
+
reusedBranch: false
|
|
1865
|
+
};
|
|
1866
|
+
const branchExists = (() => {
|
|
1867
|
+
try {
|
|
1868
|
+
runGit(absDir, [
|
|
1869
|
+
"show-ref",
|
|
1870
|
+
"--verify",
|
|
1871
|
+
`refs/heads/${branchName}`
|
|
1872
|
+
]);
|
|
1873
|
+
return true;
|
|
1874
|
+
} catch {
|
|
1875
|
+
return false;
|
|
1876
|
+
}
|
|
1877
|
+
})();
|
|
1878
|
+
try {
|
|
1879
|
+
if (branchExists) runGit(absDir, [
|
|
1880
|
+
"worktree",
|
|
1881
|
+
"add",
|
|
1882
|
+
worktreePath,
|
|
1883
|
+
branchName
|
|
1884
|
+
]);
|
|
1885
|
+
else runGit(absDir, [
|
|
1886
|
+
"worktree",
|
|
1887
|
+
"add",
|
|
1888
|
+
"-b",
|
|
1889
|
+
branchName,
|
|
1890
|
+
worktreePath,
|
|
1891
|
+
parentHeadSha
|
|
1892
|
+
]);
|
|
1893
|
+
} catch (e) {
|
|
1894
|
+
const stderr = e?.stderr?.toString?.() ?? e?.message ?? String(e);
|
|
1895
|
+
throw new Error(`git worktree add failed for ${worktreePath}: ${stderr.trim()}`);
|
|
1896
|
+
}
|
|
1897
|
+
return {
|
|
1898
|
+
worktreePath,
|
|
1899
|
+
branchName,
|
|
1900
|
+
baseSha: runGit(worktreePath, ["rev-parse", "HEAD"]),
|
|
1901
|
+
parentHeadSha,
|
|
1902
|
+
created: true,
|
|
1903
|
+
reusedBranch: branchExists
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
//#endregion
|
|
1684
1907
|
//#region src/commands/new.ts
|
|
1685
1908
|
const newCommand = define({
|
|
1686
1909
|
name: "new",
|
|
@@ -1779,14 +2002,27 @@ const newCommand = define({
|
|
|
1779
2002
|
initialPromptFile = join(tmpdir(), `cctabs-prompt-${Date.now()}.txt`);
|
|
1780
2003
|
writeFileSync(initialPromptFile, promptText);
|
|
1781
2004
|
} else if (promptFile) initialPromptFile = promptFile;
|
|
2005
|
+
let sessionDir = dir;
|
|
2006
|
+
let worktreeInfo;
|
|
2007
|
+
if (useWorktree) try {
|
|
2008
|
+
const wt = setupWorktree(dir, name);
|
|
2009
|
+
sessionDir = wt.worktreePath;
|
|
2010
|
+
worktreeInfo = wt;
|
|
2011
|
+
if (wt.created) {
|
|
2012
|
+
const branchNote = wt.reusedBranch ? ` (reused existing branch ${wt.branchName})` : "";
|
|
2013
|
+
consola.info(`Worktree created at ${wt.worktreePath} (base ${wt.baseSha.slice(0, 8)})${branchNote}`);
|
|
2014
|
+
if (wt.baseSha !== wt.parentHeadSha) consola.warn(`Worktree base ${wt.baseSha.slice(0, 8)} differs from ${dir} HEAD ${wt.parentHeadSha.slice(0, 8)} — branch '${wt.branchName}' already existed and was checked out at its prior tip.`);
|
|
2015
|
+
} else consola.info(`Worktree already present at ${wt.worktreePath} (base ${wt.baseSha.slice(0, 8)}) — reusing`);
|
|
2016
|
+
} catch (e) {
|
|
2017
|
+
consola.error(e?.message ?? String(e));
|
|
2018
|
+
process.exit(1);
|
|
2019
|
+
}
|
|
1782
2020
|
let claudeCmd;
|
|
1783
|
-
if (resolvedSessionId) {
|
|
1784
|
-
|
|
1785
|
-
claudeCmd = `claude --resume ${resolvedSessionId}${worktreePart} --name ${JSON.stringify(name)}`;
|
|
1786
|
-
} else claudeCmd = useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude";
|
|
2021
|
+
if (resolvedSessionId) claudeCmd = `claude --resume ${resolvedSessionId} --name ${JSON.stringify(name)}`;
|
|
2022
|
+
else claudeCmd = "claude";
|
|
1787
2023
|
const tabId = await openSession({
|
|
1788
2024
|
tabName: name,
|
|
1789
|
-
dir,
|
|
2025
|
+
dir: sessionDir,
|
|
1790
2026
|
claudeCmd,
|
|
1791
2027
|
workspaceQuery: workspace,
|
|
1792
2028
|
initialPromptFile,
|
|
@@ -1794,7 +2030,7 @@ const newCommand = define({
|
|
|
1794
2030
|
modelOverride: resolvedModel,
|
|
1795
2031
|
afterActive: true
|
|
1796
2032
|
});
|
|
1797
|
-
const wt =
|
|
2033
|
+
const wt = worktreeInfo ? ` (worktree: .claude/worktrees/${name} @ ${worktreeInfo.baseSha.slice(0, 8)})` : "";
|
|
1798
2034
|
const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
|
|
1799
2035
|
const rs = resolvedSessionId ? ` --resume ${resolvedSessionId.slice(0, 8)}…` : "";
|
|
1800
2036
|
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude${rs} at ${dir}${wt}${be}`);
|
|
@@ -1918,8 +2154,10 @@ const resumeCommand = define({
|
|
|
1918
2154
|
consola.error(`No terminal block found in tab '${name}'`);
|
|
1919
2155
|
process.exit(1);
|
|
1920
2156
|
}
|
|
2157
|
+
const isCurrentTab = tabId === adapter.currentTabId();
|
|
2158
|
+
const insideClaude = !!process.env.CLAUDECODE;
|
|
1921
2159
|
const status = adapter.detectSessionStatus(termBlock.blockid);
|
|
1922
|
-
if (status === "active" || status === "idle") {
|
|
2160
|
+
if ((status === "active" || status === "idle") && !(isCurrentTab && !insideClaude)) {
|
|
1923
2161
|
adapter.closeSocket();
|
|
1924
2162
|
consola.warn(`Claude is already running in tab "${name}" (${status}) — skipping resume`);
|
|
1925
2163
|
process.exit(0);
|
|
@@ -1946,6 +2184,11 @@ const resumeCommand = define({
|
|
|
1946
2184
|
const modelPart = resolvedModel ? ` --model ${JSON.stringify(resolvedModel)}` : "";
|
|
1947
2185
|
const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}${modelPart}\r`;
|
|
1948
2186
|
await adapter.sendInput(termBlock.blockid, cmd);
|
|
2187
|
+
if (isCurrentTab) {
|
|
2188
|
+
adapter.closeSocket();
|
|
2189
|
+
consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (queued in this shell)`);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
1949
2192
|
let verified = false;
|
|
1950
2193
|
const deadline = Date.now() + 15e3;
|
|
1951
2194
|
while (Date.now() < deadline) {
|
|
@@ -2218,7 +2461,7 @@ const sendCommand = define({
|
|
|
2218
2461
|
"wait-for-prompt": {
|
|
2219
2462
|
type: "boolean",
|
|
2220
2463
|
short: "w",
|
|
2221
|
-
description: "Poll the buffer until a shell prompt ($, %,
|
|
2464
|
+
description: "Poll the buffer until a ready prompt is visible before sending — a shell prompt ($, %, >) or a ready Claude TUI (❯ input line / \"auto mode\" footer). Useful for freshly-spawned tabs."
|
|
2222
2465
|
},
|
|
2223
2466
|
"wait-timeout": {
|
|
2224
2467
|
type: "number",
|
|
@@ -2240,7 +2483,11 @@ const sendCommand = define({
|
|
|
2240
2483
|
if (inlineText !== void 0) rawText = inlineText.replace(/\\n/g, "\r").replace(/\\r/g, "\r").replace(/\\t/g, " ");
|
|
2241
2484
|
else if (filePath) rawText = readFileSync(filePath, "utf-8").replace(/\n/g, "\r");
|
|
2242
2485
|
else rawText = (await readStdin()).replace(/\n/g, "\r");
|
|
2243
|
-
|
|
2486
|
+
let sendEnter = appendEnter;
|
|
2487
|
+
if (rawText.endsWith("\r")) {
|
|
2488
|
+
rawText = rawText.replace(/\r+$/, "");
|
|
2489
|
+
sendEnter = true;
|
|
2490
|
+
}
|
|
2244
2491
|
const adapter = requireAdapter();
|
|
2245
2492
|
const { tabsById, tabNames } = await adapter.getAllData();
|
|
2246
2493
|
const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
|
|
@@ -2274,8 +2521,10 @@ const sendCommand = define({
|
|
|
2274
2521
|
const deadline = Date.now() + waitTimeoutSec * 1e3;
|
|
2275
2522
|
let ready = false;
|
|
2276
2523
|
while (Date.now() < deadline) {
|
|
2277
|
-
const
|
|
2278
|
-
|
|
2524
|
+
const tail = adapter.scrollback(blockId, 8);
|
|
2525
|
+
const lastLine = tail.split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
|
|
2526
|
+
const stripped = tail.replace(/\s+/g, "");
|
|
2527
|
+
if (/[$%>]\s*$/.test(lastLine) || /^❯/.test(lastLine) || /automode|foragents/i.test(stripped)) {
|
|
2279
2528
|
ready = true;
|
|
2280
2529
|
break;
|
|
2281
2530
|
}
|
|
@@ -2283,18 +2532,25 @@ const sendCommand = define({
|
|
|
2283
2532
|
}
|
|
2284
2533
|
if (!ready) {
|
|
2285
2534
|
adapter.closeSocket();
|
|
2286
|
-
consola.error(`Timed out after ${waitTimeoutSec}s waiting for
|
|
2535
|
+
consola.error(`Timed out after ${waitTimeoutSec}s waiting for a ready prompt in ${blockId.slice(0, 8)}`);
|
|
2287
2536
|
process.exit(1);
|
|
2288
2537
|
}
|
|
2289
2538
|
}
|
|
2290
|
-
|
|
2539
|
+
let resp;
|
|
2540
|
+
if (rawText.length > 0) resp = await adapter.sendInput(blockId, rawText);
|
|
2541
|
+
if (sendEnter) {
|
|
2542
|
+
if (rawText.length > 0) await new Promise((r) => setTimeout(r, 200));
|
|
2543
|
+
const enterResp = await adapter.sendInput(blockId, "\r");
|
|
2544
|
+
resp ??= enterResp;
|
|
2545
|
+
}
|
|
2291
2546
|
adapter.closeSocket();
|
|
2292
2547
|
if (resp && resp.error) {
|
|
2293
2548
|
consola.error(String(resp.error));
|
|
2294
2549
|
process.exit(1);
|
|
2295
2550
|
}
|
|
2296
2551
|
const preview = rawText.slice(0, 80).replace(/\n/g, "↵").replace(/\t/g, "→");
|
|
2297
|
-
|
|
2552
|
+
const label = rawText.length > 0 ? `${JSON.stringify(preview)}${rawText.length > 80 ? "…" : ""}${sendEnter ? " ⏎" : ""}` : "⏎";
|
|
2553
|
+
consola.success(`Sent to ${blockId.slice(0, 8)}: ${label}`);
|
|
2298
2554
|
}
|
|
2299
2555
|
});
|
|
2300
2556
|
//#endregion
|
|
@@ -2433,15 +2689,27 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
|
|
|
2433
2689
|
const extraFlags = loadConfig().claude.flags.map(shellQuoteArg).join(" ");
|
|
2434
2690
|
for (const entry of entries) {
|
|
2435
2691
|
let resolvedSessionId = entry.session_id;
|
|
2692
|
+
let resolvedDir = entry.dir;
|
|
2436
2693
|
if (entry.session_id) {
|
|
2437
2694
|
const expanded = expandSessionId(entry.session_id, entry.dir) ?? expandSessionId(entry.session_id);
|
|
2438
2695
|
if (expanded) resolvedSessionId = expanded;
|
|
2439
2696
|
} else {
|
|
2440
|
-
const
|
|
2441
|
-
if (
|
|
2442
|
-
|
|
2697
|
+
const resolved = resolveTabSession(entry.dir, entry.name);
|
|
2698
|
+
if (resolved) {
|
|
2699
|
+
resolvedSessionId = resolved.id;
|
|
2700
|
+
resolvedDir = resolved.dir;
|
|
2701
|
+
}
|
|
2443
2702
|
}
|
|
2444
|
-
const
|
|
2703
|
+
const allMatches = adapter.resolveTab(entry.name, tabsById, tabNames).filter((tid) => wsTabIds.has(tid));
|
|
2704
|
+
if (allMatches.length === 1 && allMatches[0] === currentTab) {
|
|
2705
|
+
consola.log(` ${entry.name} — current tab, already present`);
|
|
2706
|
+
results.push({
|
|
2707
|
+
name: entry.name,
|
|
2708
|
+
result: "current tab — already present"
|
|
2709
|
+
});
|
|
2710
|
+
continue;
|
|
2711
|
+
}
|
|
2712
|
+
const matchingTabs = allMatches.filter((tid) => tid !== currentTab);
|
|
2445
2713
|
if (matchingTabs.length > 1) {
|
|
2446
2714
|
consola.log(` ${entry.name} — multiple matching tabs, skipping`);
|
|
2447
2715
|
results.push({
|
|
@@ -2486,7 +2754,7 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
|
|
|
2486
2754
|
continue;
|
|
2487
2755
|
}
|
|
2488
2756
|
consola.log(` ${entry.name} → resuming ${resolvedSessionId.slice(0, 8)}… in existing tab`);
|
|
2489
|
-
const cmd = `cd ${JSON.stringify(
|
|
2757
|
+
const cmd = `cd ${JSON.stringify(resolvedDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${resolvedSessionId} --name ${JSON.stringify(entry.name)}\r`;
|
|
2490
2758
|
await adapter.sendInput(termBlock.blockid, cmd);
|
|
2491
2759
|
await new Promise((r) => setTimeout(r, 500));
|
|
2492
2760
|
results.push({
|
|
@@ -2505,7 +2773,7 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
|
|
|
2505
2773
|
}
|
|
2506
2774
|
if (dryRun) {
|
|
2507
2775
|
const sid = resolvedSessionId ? `${resolvedSessionId.slice(0, 8)}…` : "fresh";
|
|
2508
|
-
consola.log(` ${entry.name} → would spawn new tab in ${
|
|
2776
|
+
consola.log(` ${entry.name} → would spawn new tab in ${resolvedDir} (${sid})`);
|
|
2509
2777
|
results.push({
|
|
2510
2778
|
name: entry.name,
|
|
2511
2779
|
result: `dry run: spawn (${sid})`
|
|
@@ -2514,6 +2782,7 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
|
|
|
2514
2782
|
}
|
|
2515
2783
|
toSpawn.push({
|
|
2516
2784
|
...entry,
|
|
2785
|
+
dir: resolvedDir,
|
|
2517
2786
|
session_id: resolvedSessionId
|
|
2518
2787
|
});
|
|
2519
2788
|
}
|
|
@@ -3511,6 +3780,86 @@ const importCommand = define({
|
|
|
3511
3780
|
}
|
|
3512
3781
|
});
|
|
3513
3782
|
//#endregion
|
|
3783
|
+
//#region src/commands/sort.ts
|
|
3784
|
+
function relAge(ms) {
|
|
3785
|
+
const s = Math.floor(ms / 1e3);
|
|
3786
|
+
if (s < 60) return `${s}s ago`;
|
|
3787
|
+
const m = Math.floor(s / 60);
|
|
3788
|
+
if (m < 60) return `${m}m ago`;
|
|
3789
|
+
const h = Math.floor(m / 60);
|
|
3790
|
+
if (h < 48) return `${h}h ago`;
|
|
3791
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
3792
|
+
}
|
|
3793
|
+
const sortCommand = define({
|
|
3794
|
+
name: "sort",
|
|
3795
|
+
description: "Reorder tabs by Claude session activity (most-recent first).",
|
|
3796
|
+
args: {
|
|
3797
|
+
"dry-run": {
|
|
3798
|
+
type: "boolean",
|
|
3799
|
+
short: "n",
|
|
3800
|
+
description: "Show planned order without applying it"
|
|
3801
|
+
},
|
|
3802
|
+
reverse: {
|
|
3803
|
+
type: "boolean",
|
|
3804
|
+
short: "r",
|
|
3805
|
+
description: "Oldest first instead of newest"
|
|
3806
|
+
}
|
|
3807
|
+
},
|
|
3808
|
+
async run(ctx) {
|
|
3809
|
+
const dryRun = !!ctx.values["dry-run"];
|
|
3810
|
+
const reverse = !!ctx.values.reverse;
|
|
3811
|
+
const adapter = requireAdapter();
|
|
3812
|
+
if (typeof adapter.reorderTabs !== "function") {
|
|
3813
|
+
consola.error("Tab reordering is not supported by this terminal (Tabby only for now).");
|
|
3814
|
+
process.exit(1);
|
|
3815
|
+
}
|
|
3816
|
+
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
3817
|
+
const titleMtimes = buildTitleActivityMap();
|
|
3818
|
+
const now = Date.now();
|
|
3819
|
+
for (const wsp of workspaces) {
|
|
3820
|
+
const tabIds = wsp.workspacedata.tabids.filter((t) => tabsById.has(t));
|
|
3821
|
+
if (!tabIds.length) continue;
|
|
3822
|
+
const ranked = tabIds.map((tid, origIndex) => {
|
|
3823
|
+
const name = tabNames.get(tid) ?? tid.slice(0, 8);
|
|
3824
|
+
return {
|
|
3825
|
+
tid,
|
|
3826
|
+
name,
|
|
3827
|
+
mtime: titleMtimes.get(name) ?? 0,
|
|
3828
|
+
origIndex
|
|
3829
|
+
};
|
|
3830
|
+
});
|
|
3831
|
+
ranked.sort((a, b) => {
|
|
3832
|
+
if (!a.mtime && !b.mtime) return a.origIndex - b.origIndex;
|
|
3833
|
+
if (!a.mtime) return 1;
|
|
3834
|
+
if (!b.mtime) return -1;
|
|
3835
|
+
return reverse ? a.mtime - b.mtime : b.mtime - a.mtime;
|
|
3836
|
+
});
|
|
3837
|
+
consola.info(`${reverse ? "Oldest" : "Newest"} first:`);
|
|
3838
|
+
for (const r of ranked) {
|
|
3839
|
+
const age = r.mtime ? relAge(now - r.mtime) : "(no session)";
|
|
3840
|
+
consola.log(` ${r.name.padEnd(32)} ${age}`);
|
|
3841
|
+
}
|
|
3842
|
+
const desiredOrder = ranked.map((r) => r.tid);
|
|
3843
|
+
if (desiredOrder.every((id, i) => id === tabIds[i])) {
|
|
3844
|
+
consola.info("Already in order.");
|
|
3845
|
+
continue;
|
|
3846
|
+
}
|
|
3847
|
+
if (dryRun) {
|
|
3848
|
+
consola.info("Dry run — no changes applied.");
|
|
3849
|
+
continue;
|
|
3850
|
+
}
|
|
3851
|
+
try {
|
|
3852
|
+
await adapter.reorderTabs(desiredOrder);
|
|
3853
|
+
consola.success(`Reordered ${desiredOrder.length} tab(s).`);
|
|
3854
|
+
} catch (err) {
|
|
3855
|
+
consola.error(`Failed to reorder tabs: ${err.message}`);
|
|
3856
|
+
process.exitCode = 1;
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
adapter.closeSocket();
|
|
3860
|
+
}
|
|
3861
|
+
});
|
|
3862
|
+
//#endregion
|
|
3514
3863
|
//#region src/commands/index.ts
|
|
3515
3864
|
const defaultCommand = define({
|
|
3516
3865
|
name: "cctabs",
|
|
@@ -3537,7 +3886,8 @@ const subCommands = new Map([
|
|
|
3537
3886
|
["doctor", doctorCommand],
|
|
3538
3887
|
["install-tabby-plugin", installTabbyPluginCommand],
|
|
3539
3888
|
["export", exportCommand],
|
|
3540
|
-
["import", importCommand]
|
|
3889
|
+
["import", importCommand],
|
|
3890
|
+
["sort", sortCommand]
|
|
3541
3891
|
]);
|
|
3542
3892
|
async function run() {
|
|
3543
3893
|
await cli(process.argv.slice(2), defaultCommand, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@generativereality/cctabs",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
"dev": "bun run ./src/index.ts",
|
|
16
16
|
"build": "tsdown",
|
|
17
17
|
"typecheck": "tsc --noEmit",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"test:watch": "bun test --watch",
|
|
18
20
|
"lint": "eslint src/",
|
|
19
|
-
"check": "npm run typecheck && npm run build",
|
|
21
|
+
"check": "npm run typecheck && npm run test && npm run build",
|
|
20
22
|
"release": "bumpp && npm publish",
|
|
21
23
|
"sync-plugin": "bash scripts/sync-plugin.sh",
|
|
22
24
|
"build:tabby-plugin": "cd tabby-plugin && npm install && npm run build",
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
"update-notifier": "^7.3.1"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
57
|
+
"@types/bun": "^1.3.14",
|
|
55
58
|
"@types/node": "^22.0.0",
|
|
56
59
|
"@types/update-notifier": "^6.0.8",
|
|
57
60
|
"bumpp": "^9.11.1",
|
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -3,9 +3,9 @@ name: cctabs
|
|
|
3
3
|
description: |
|
|
4
4
|
Manage Claude Code sessions across terminal tabs (Wave Terminal or Tabby) — list, open, fork, close, inspect output, send input. Each terminal tab runs its own Claude Code session.
|
|
5
5
|
|
|
6
|
-
TRIGGER when the user says any of: "open a new tab", "open a new cctab" (singular alias), "spawn a tab", "a new cctabs session", "in another tab", "in a separate tab", "fork this tab", "list my tabs", "close that tab", "send to <tab>", "resume <name>" — anything that refers to a terminal tab running Claude Code. ALSO trigger for: "/cctabs", or when the user mentions Wave Terminal / Tabby tab management for Claude Code.
|
|
6
|
+
TRIGGER when the user says any of: "open a tab", "open a new tab", "open a tab with prompt …", "open a tab and <do X>", "open a tab that <does X>", "open a new cctab" (singular alias), "spawn a tab", "a new cctabs session", "in another tab", "in a separate tab", "fork this tab", "list my tabs", "close that tab", "send to <tab>", "resume <name>" — anything that refers to a terminal tab running Claude Code. ALSO trigger for: "/cctabs", or when the user mentions Wave Terminal / Tabby tab management for Claude Code.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
The word "tab" is DECISIVE. If the user says "tab" / "cctab" / "cctabs" — even paired with a task, and even when that task sounds like background or parallel work (e.g. "open a tab with prompt 'do X asap'", "open a tab and fix Y") — they mean a real terminal tab running its own Claude Code session: CALL THIS SKILL, not the Agent tool. Handing a task to a fresh tab is the single most common use: "open a tab with prompt <task>" maps directly to `cctabs new <name> [dir] --prompt "<task>"`. A background/fork subagent (the Agent tool) is NOT a tab and must never be substituted when the user said "tab" — its output is invisible in the terminal and it cannot be attached to, resumed, watched, or driven as a session. Use the Agent tool ONLY when the user explicitly says "subagent", "background agent", "spawn an agent", "do this in parallel without a new tab", or when the work is tightly interconnected with the current session's filesystem state and must share it.
|
|
9
9
|
|
|
10
10
|
NOT for: browser tabs (use playwright/browser-automation), tmux panes, screen sessions, or non-Claude terminals.
|
|
11
11
|
---
|
|
@@ -358,13 +358,10 @@ cctabs send payments "yes\n" # quick replies
|
|
|
358
358
|
|
|
359
359
|
### Spawning gotchas (hard-won)
|
|
360
360
|
|
|
361
|
-
1. **
|
|
361
|
+
1. **Worktree base.** `cctabs new --worktree` anchors the new worktree at the target dir's current HEAD (cctabs runs `git worktree add` explicitly, not delegating to `claude --worktree`). The spawn line confirms the base SHA, e.g. `Worktree created at … (base 9d4a26d…)`. If a branch named `worktree-<name>` already exists from a prior run, the worktree is checked out at *that branch's* tip and cctabs prints a warning — verify it's what you want before sending work into the tab. To double-check after spawn:
|
|
362
362
|
```bash
|
|
363
|
-
cctabs new kid ~/Dev/myapp --worktree -p "..."
|
|
364
|
-
# Then in the ORCHESTRATOR tab:
|
|
365
363
|
git -C ~/Dev/myapp/.claude/worktrees/kid log --oneline -1
|
|
366
364
|
```
|
|
367
|
-
If the base is not what you expected, abort and fix: either push your commits to the tracking branch first, or spawn without `--worktree` and let the subagent work on your branch directly.
|
|
368
365
|
|
|
369
366
|
2. **Never instruct a subagent to "rebase your branch on main/next."** Subagents interpret this liberally. A common failure mode: the subagent does `git reset --hard <remote>` and throws away its own completed commits, trying to redo the work from scratch. Instead:
|
|
370
367
|
- Have the orchestrator handle rebases after the subagent is done.
|
|
@@ -400,9 +397,10 @@ echo "do the thing" | cctabs send auth # pipe via stdin
|
|
|
400
397
|
|
|
401
398
|
```bash
|
|
402
399
|
cctabs new feature-name ~/Dev/myapp --worktree
|
|
403
|
-
#
|
|
404
|
-
#
|
|
405
|
-
#
|
|
400
|
+
# cctabs creates the worktree itself, pinned to ~/Dev/myapp's current HEAD:
|
|
401
|
+
# git -C ~/Dev/myapp worktree add -b worktree-feature-name \
|
|
402
|
+
# ~/Dev/myapp/.claude/worktrees/feature-name <current HEAD>
|
|
403
|
+
# Then opens a tab at the worktree path and runs plain `claude --name feature-name`.
|
|
406
404
|
```
|
|
407
405
|
|
|
408
406
|
### Existing branch — ask Claude to enter the worktree mid-session
|
|
@@ -426,7 +424,7 @@ cctabs new feature ~/Dev/myapp --worktree
|
|
|
426
424
|
|
|
427
425
|
**Why:** Manually created worktree dirs placed outside the repo confuse Claude Code's session tracking, project memory lookup (`.claude/` is in the main repo), and CLAUDE.md resolution. Claude Code's built-in worktree support keeps everything co-located under `.claude/worktrees/` and handles cleanup on session exit.
|
|
428
426
|
|
|
429
|
-
**Worktree base
|
|
427
|
+
**Worktree base commit:** cctabs anchors the new worktree at the target dir's current HEAD (it runs `git worktree add` explicitly rather than delegating to `claude --worktree`), so un-pushed local commits *are* visible to the child session. The success line prints the base SHA — confirm it matches what you expect, especially if you reuse a worktree name and see a "branch already existed" warning.
|
|
430
428
|
|
|
431
429
|
## Handling `cctabs new` Timeout Errors
|
|
432
430
|
|