@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.
@@ -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.5",
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.5";
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 matches = findSessionsByName(cwd, tabName);
1124
- if (matches.length) sessionId = matches[0].id;
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, "❯", "Claude prompt", 3e4);
1473
+ await waitForScrollbackMatch(adapter, blockId, readySignal, "Claude prompt", 45e3);
1333
1474
  } catch {
1334
- adapter.closeSocket();
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 < 4; attempt++) {
1508
+ for (let attempt = 0; attempt < 8; attempt++) {
1369
1509
  await adapter.sendInput(blockId, "\r");
1370
- await sleep(500);
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
- const worktreePart = useWorktree ? ` --worktree ${JSON.stringify(name)}` : "";
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 = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
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 ($, %, >,) is visible before sending. Useful for freshly-spawned tabs."
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
- if (appendEnter && !rawText.endsWith("\r")) rawText += "\r";
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 lastLine = adapter.scrollback(blockId, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
2278
- if (/[$%>❯]\s*$/.test(lastLine)) {
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 shell prompt in ${blockId.slice(0, 8)}`);
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
- const resp = await adapter.sendInput(blockId, rawText);
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
- consola.success(`Sent to ${blockId.slice(0, 8)}: ${JSON.stringify(preview)}${rawText.length > 80 ? "…" : ""}`);
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 sessions = findSessionsByName(entry.dir, entry.name);
2441
- if (sessions.length === 1) resolvedSessionId = sessions[0].id;
2442
- else if (sessions.length > 1) resolvedSessionId = sessions[0].id;
2697
+ const resolved = resolveTabSession(entry.dir, entry.name);
2698
+ if (resolved) {
2699
+ resolvedSessionId = resolved.id;
2700
+ resolvedDir = resolved.dir;
2701
+ }
2443
2702
  }
2444
- const matchingTabs = adapter.resolveTab(entry.name, tabsById, tabNames).filter((tid) => wsTabIds.has(tid) && tid !== currentTab);
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(entry.dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${resolvedSessionId} --name ${JSON.stringify(entry.name)}\r`;
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 ${entry.dir} (${sid})`);
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.5",
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",
@@ -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
- DO NOT confuse with the Agent tool (background subagents): if the user explicitly says "tab" / "cctab" / "cctabs" they want a separate Claude Code session in a real terminal tab call this skill, not Agent. The Agent tool is correct when the user says "subagent", "background agent", "spawn an agent", "do this in parallel without a new tab", or when the work is interconnected with the current session's filesystem state.
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. **Verify the worktree base immediately after spawn.** `--worktree` does not always branch from your current HEAD if you have local un-pushed commits, the child session may branch from an older commit (whatever the remote tracking branch points at). Always check:
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
- # Equivalent to: cd ~/Dev/myapp && claude --worktree "feature-name" --name "feature-name"
404
- # Claude creates: ~/Dev/myapp/.claude/worktrees/feature-name/
405
- # Claude creates branch: worktree-feature-name
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-commit caveat:** after spawning with `--worktree`, verify the branch base matches your expectation (see "Spawning gotchas" above). If your orchestrator has local commits that haven't been pushed, the worktree may branch from the stale remote tip instead of HEAD. This bites hardest when parallel tabs need to share schema/types your orchestrator has been working on they won't see those changes if they branched before the commits landed upstream.
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