@generativereality/cctabs 0.4.4 → 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.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.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",
@@ -709,7 +712,8 @@ var TabbyAdapter = class {
709
712
  cwd: opts.cwd,
710
713
  title: opts.title,
711
714
  command: opts.command,
712
- args: opts.args
715
+ args: opts.args,
716
+ afterActive: opts.afterActive ?? false
713
717
  }))?.uuid;
714
718
  if (!uuid) throw new Error("Tabby plugin did not return a tab uuid");
715
719
  return {
@@ -717,6 +721,10 @@ var TabbyAdapter = class {
717
721
  tabId: uuid
718
722
  };
719
723
  }
724
+ async reorderTabs(order) {
725
+ this.ensureHealthy();
726
+ await this.http("POST", "/api/tabs/reorder", { order });
727
+ }
720
728
  async sendInput(blockId, text) {
721
729
  return this.http("POST", `/api/tabs/${blockId}/send`, { data: text });
722
730
  }
@@ -952,6 +960,97 @@ function findSessionsByName(dir, name) {
952
960
  return matches.sort((a, b) => b.mtime - a.mtime);
953
961
  }
954
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
+ /**
955
1054
  * Like findSessionsByName, but searches every project directory under
956
1055
  * ~/.claude/projects. Each match carries the cwd recorded in the session.
957
1056
  * Used by `cctabs restore` so callers don't have to guess the right dir.
@@ -1018,6 +1117,48 @@ function findSessionsByNameGlobally(name) {
1018
1117
  return matches.sort((a, b) => b.mtime - a.mtime);
1019
1118
  }
1020
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
+ /**
1021
1162
  * List all unique session names (customTitle) in a project directory.
1022
1163
  * Used to show available names when a resume lookup fails.
1023
1164
  */
@@ -1114,15 +1255,19 @@ const sessionsCommand = define({
1114
1255
  const status = adapter.detectSessionStatus(b.blockid);
1115
1256
  const lastLine = adapter.scrollback(b.blockid, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
1116
1257
  let sessionId = null;
1258
+ let sessionDir = cwd;
1117
1259
  if (cwd) try {
1118
- const matches = findSessionsByName(cwd, tabName);
1119
- 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
+ }
1120
1265
  } catch {}
1121
1266
  wsRow.sessions.push({
1122
1267
  block_id: b.blockid,
1123
1268
  tab_id: tabId,
1124
1269
  name: tabName,
1125
- cwd,
1270
+ cwd: sessionDir,
1126
1271
  current: tabId === currentTab,
1127
1272
  status,
1128
1273
  last_line: lastLine.slice(0, 200),
@@ -1323,11 +1468,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1323
1468
  * long / multi-line prompt collapses to a "[Pasted text #N +M lines]" chip.
1324
1469
  */
1325
1470
  async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1471
+ const readySignal = /❯|auto mode|for agents|Try ["'“]/;
1326
1472
  try {
1327
- await waitForScrollbackMatch(adapter, blockId, "❯", "Claude prompt", 3e4);
1473
+ await waitForScrollbackMatch(adapter, blockId, readySignal, "Claude prompt", 45e3);
1328
1474
  } catch {
1329
- adapter.closeSocket();
1330
- 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).");
1331
1476
  }
1332
1477
  if (/trustthisfolder|Yes,?Itrustthis|Isthisaproject/i.test(adapter.scrollback(blockId, 40).replace(/\s+/g, ""))) for (let attempt = 0; attempt < 18; attempt++) {
1333
1478
  const screen = adapter.scrollback(blockId, 14).replace(/\s+/g, "");
@@ -1360,17 +1505,65 @@ async function sendInitialPrompt(adapter, blockId, initialPromptFile) {
1360
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).");
1361
1506
  return;
1362
1507
  }
1363
- for (let attempt = 0; attempt < 4; attempt++) {
1508
+ for (let attempt = 0; attempt < 8; attempt++) {
1364
1509
  await adapter.sendInput(blockId, "\r");
1365
- await sleep(500);
1510
+ await sleep(700);
1366
1511
  const tail = adapter.scrollback(blockId, 40);
1367
1512
  if (/[✻✽✶✳✢]/.test(tail) || /esctointerrupt/i.test(tail.replace(/\s+/g, ""))) return;
1368
1513
  }
1369
1514
  consola.warn("Could not confirm the initial prompt was submitted — switch to the tab and press Enter to send it.");
1370
1515
  }
1516
+ /**
1517
+ * Auto-advance Claude's "resume" picker that appears when `claude --resume <id>`
1518
+ * reattaches a large/old session:
1519
+ *
1520
+ * ❯ 1. Resume from summary (recommended)
1521
+ * 2. Resume full session as-is
1522
+ * 3. Don't ask me again
1523
+ *
1524
+ * It blocks the tab until you choose, which is why a plain `restore` leaves
1525
+ * such tabs stuck. cctabs always wants the FULL session — the whole point of
1526
+ * restore is to bring the conversation back intact, not a lossy summary — so we
1527
+ * select option 2.
1528
+ *
1529
+ * Like the trust dialog, the picker has a brief not-ready window as it paints,
1530
+ * so we poll for it to appear, settle, then navigate. The default highlight is
1531
+ * option 1, so we move the cursor DOWN exactly once to reach option 2. We send
1532
+ * ↓ only once on purpose: spamming it across retries could land on option 3
1533
+ * ("Don't ask me again"), which permanently changes the user's config. The
1534
+ * confirm is the part we retry — re-pressing Enter on the same row is safe, and
1535
+ * if the single ↓ was ever dropped the worst case is a (still-usable) summary
1536
+ * resume, never option 3.
1537
+ */
1538
+ async function confirmResumePicker(adapter, blockId) {
1539
+ const stripped = (n) => adapter.scrollback(blockId, n).replace(/\s+/g, "");
1540
+ const pickerVisible = (n) => {
1541
+ const c = stripped(n);
1542
+ return /Resumefromsummary/i.test(c) && /Resumefullsession/i.test(c);
1543
+ };
1544
+ let appeared = false;
1545
+ for (let i = 0; i < 25; i++) {
1546
+ if (pickerVisible(30)) {
1547
+ appeared = true;
1548
+ break;
1549
+ }
1550
+ await sleep(1e3);
1551
+ }
1552
+ if (!appeared) return;
1553
+ await sleep(1200);
1554
+ await adapter.sendInput(blockId, "\x1B[B");
1555
+ await sleep(250);
1556
+ for (let attempt = 0; attempt < 8; attempt++) {
1557
+ await adapter.sendInput(blockId, "\r");
1558
+ await sleep(900);
1559
+ if (!pickerVisible(8)) return;
1560
+ }
1561
+ consola.warn("Could not confirm the resume picker was dismissed — switch to the tab and pick \"Resume full session as-is\".");
1562
+ }
1371
1563
  async function openSession(opts) {
1372
- const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride } = opts;
1564
+ const { tabName, claudeCmd, workspaceQuery, initialPromptFile, envVars, modelOverride, afterActive } = opts;
1373
1565
  const tailDelayMs = opts.tailDelayMs ?? 2e3;
1566
+ const isResume = /--resume\b/.test(claudeCmd);
1374
1567
  const dir = resolve(opts.dir.replace(/^~/, homedir()));
1375
1568
  if (!existsSync(dir)) throw new Error(`Directory does not exist: ${dir}`);
1376
1569
  const config = loadConfig();
@@ -1400,9 +1593,14 @@ async function openSession(opts) {
1400
1593
  "-i",
1401
1594
  "-c",
1402
1595
  launch
1403
- ]
1596
+ ],
1597
+ afterActive
1404
1598
  });
1405
1599
  mark("openTabDirect");
1600
+ if (isResume) {
1601
+ await confirmResumePicker(adapter, blockId);
1602
+ mark("resumePicker");
1603
+ }
1406
1604
  if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
1407
1605
  adapter.closeSocket();
1408
1606
  return tabId;
@@ -1439,6 +1637,10 @@ async function openSession(opts) {
1439
1637
  const envPrefix = envVars ? shellQuoteEnv$1(envVars) : "";
1440
1638
  const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} ${claudeCmd.replace(/^claude\s*/, "")}${namePart}${modelPart}\r`;
1441
1639
  await adapter.sendInput(blockId, cmd);
1640
+ if (isResume) {
1641
+ await confirmResumePicker(adapter, blockId);
1642
+ mark("resumePicker");
1643
+ }
1442
1644
  if (initialPromptFile) await sendInitialPrompt(adapter, blockId, initialPromptFile);
1443
1645
  if (tailDelayMs > 0) await new Promise((r) => setTimeout(r, tailDelayMs));
1444
1646
  mark("tail");
@@ -1619,6 +1821,89 @@ function listBackends() {
1619
1821
  }));
1620
1822
  }
1621
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
1622
1907
  //#region src/commands/new.ts
1623
1908
  const newCommand = define({
1624
1909
  name: "new",
@@ -1717,21 +2002,35 @@ const newCommand = define({
1717
2002
  initialPromptFile = join(tmpdir(), `cctabs-prompt-${Date.now()}.txt`);
1718
2003
  writeFileSync(initialPromptFile, promptText);
1719
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
+ }
1720
2020
  let claudeCmd;
1721
- if (resolvedSessionId) {
1722
- const worktreePart = useWorktree ? ` --worktree ${JSON.stringify(name)}` : "";
1723
- claudeCmd = `claude --resume ${resolvedSessionId}${worktreePart} --name ${JSON.stringify(name)}`;
1724
- } else claudeCmd = useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude";
2021
+ if (resolvedSessionId) claudeCmd = `claude --resume ${resolvedSessionId} --name ${JSON.stringify(name)}`;
2022
+ else claudeCmd = "claude";
1725
2023
  const tabId = await openSession({
1726
2024
  tabName: name,
1727
- dir,
2025
+ dir: sessionDir,
1728
2026
  claudeCmd,
1729
2027
  workspaceQuery: workspace,
1730
2028
  initialPromptFile,
1731
2029
  envVars,
1732
- modelOverride: resolvedModel
2030
+ modelOverride: resolvedModel,
2031
+ afterActive: true
1733
2032
  });
1734
- const wt = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
2033
+ const wt = worktreeInfo ? ` (worktree: .claude/worktrees/${name} @ ${worktreeInfo.baseSha.slice(0, 8)})` : "";
1735
2034
  const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
1736
2035
  const rs = resolvedSessionId ? ` --resume ${resolvedSessionId.slice(0, 8)}…` : "";
1737
2036
  consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude${rs} at ${dir}${wt}${be}`);
@@ -1855,8 +2154,10 @@ const resumeCommand = define({
1855
2154
  consola.error(`No terminal block found in tab '${name}'`);
1856
2155
  process.exit(1);
1857
2156
  }
2157
+ const isCurrentTab = tabId === adapter.currentTabId();
2158
+ const insideClaude = !!process.env.CLAUDECODE;
1858
2159
  const status = adapter.detectSessionStatus(termBlock.blockid);
1859
- if (status === "active" || status === "idle") {
2160
+ if ((status === "active" || status === "idle") && !(isCurrentTab && !insideClaude)) {
1860
2161
  adapter.closeSocket();
1861
2162
  consola.warn(`Claude is already running in tab "${name}" (${status}) — skipping resume`);
1862
2163
  process.exit(0);
@@ -1871,7 +2172,8 @@ const resumeCommand = define({
1871
2172
  dir,
1872
2173
  claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1873
2174
  envVars,
1874
- modelOverride: resolvedModel
2175
+ modelOverride: resolvedModel,
2176
+ afterActive: true
1875
2177
  });
1876
2178
  consola.success(`Tab "${name}" [${newTabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (recreated)`);
1877
2179
  return;
@@ -1882,6 +2184,11 @@ const resumeCommand = define({
1882
2184
  const modelPart = resolvedModel ? ` --model ${JSON.stringify(resolvedModel)}` : "";
1883
2185
  const cmd = `cd ${JSON.stringify(dir)} && ${envPrefix}claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}${modelPart}\r`;
1884
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
+ }
1885
2192
  let verified = false;
1886
2193
  const deadline = Date.now() + 15e3;
1887
2194
  while (Date.now() < deadline) {
@@ -1902,7 +2209,8 @@ const resumeCommand = define({
1902
2209
  dir,
1903
2210
  claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`,
1904
2211
  envVars,
1905
- modelOverride: resolvedModel
2212
+ modelOverride: resolvedModel,
2213
+ afterActive: true
1906
2214
  });
1907
2215
  consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (new tab)`);
1908
2216
  } else {
@@ -1987,7 +2295,8 @@ const forkCommand = define({
1987
2295
  const newTabId = await openSession({
1988
2296
  tabName: newName,
1989
2297
  dir: openDir,
1990
- claudeCmd: `claude --resume ${sessionId} --fork-session`
2298
+ claudeCmd: `claude --resume ${sessionId} --fork-session`,
2299
+ afterActive: true
1991
2300
  });
1992
2301
  consola.success(`Forked "${tabName}" → "${newName}" [${newTabId.slice(0, 8)}]`);
1993
2302
  consola.info(`session: ${sessionId}`);
@@ -2152,7 +2461,7 @@ const sendCommand = define({
2152
2461
  "wait-for-prompt": {
2153
2462
  type: "boolean",
2154
2463
  short: "w",
2155
- 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."
2156
2465
  },
2157
2466
  "wait-timeout": {
2158
2467
  type: "number",
@@ -2174,7 +2483,11 @@ const sendCommand = define({
2174
2483
  if (inlineText !== void 0) rawText = inlineText.replace(/\\n/g, "\r").replace(/\\r/g, "\r").replace(/\\t/g, " ");
2175
2484
  else if (filePath) rawText = readFileSync(filePath, "utf-8").replace(/\n/g, "\r");
2176
2485
  else rawText = (await readStdin()).replace(/\n/g, "\r");
2177
- 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
+ }
2178
2491
  const adapter = requireAdapter();
2179
2492
  const { tabsById, tabNames } = await adapter.getAllData();
2180
2493
  const tabMatches = adapter.resolveTab(query, tabsById, tabNames);
@@ -2208,8 +2521,10 @@ const sendCommand = define({
2208
2521
  const deadline = Date.now() + waitTimeoutSec * 1e3;
2209
2522
  let ready = false;
2210
2523
  while (Date.now() < deadline) {
2211
- const lastLine = adapter.scrollback(blockId, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
2212
- 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)) {
2213
2528
  ready = true;
2214
2529
  break;
2215
2530
  }
@@ -2217,18 +2532,25 @@ const sendCommand = define({
2217
2532
  }
2218
2533
  if (!ready) {
2219
2534
  adapter.closeSocket();
2220
- 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)}`);
2221
2536
  process.exit(1);
2222
2537
  }
2223
2538
  }
2224
- 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
+ }
2225
2546
  adapter.closeSocket();
2226
2547
  if (resp && resp.error) {
2227
2548
  consola.error(String(resp.error));
2228
2549
  process.exit(1);
2229
2550
  }
2230
2551
  const preview = rawText.slice(0, 80).replace(/\n/g, "↵").replace(/\t/g, "→");
2231
- 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}`);
2232
2554
  }
2233
2555
  });
2234
2556
  //#endregion
@@ -2367,15 +2689,27 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
2367
2689
  const extraFlags = loadConfig().claude.flags.map(shellQuoteArg).join(" ");
2368
2690
  for (const entry of entries) {
2369
2691
  let resolvedSessionId = entry.session_id;
2692
+ let resolvedDir = entry.dir;
2370
2693
  if (entry.session_id) {
2371
2694
  const expanded = expandSessionId(entry.session_id, entry.dir) ?? expandSessionId(entry.session_id);
2372
2695
  if (expanded) resolvedSessionId = expanded;
2373
2696
  } else {
2374
- const sessions = findSessionsByName(entry.dir, entry.name);
2375
- if (sessions.length === 1) resolvedSessionId = sessions[0].id;
2376
- 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
+ }
2702
+ }
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;
2377
2711
  }
2378
- const matchingTabs = adapter.resolveTab(entry.name, tabsById, tabNames).filter((tid) => wsTabIds.has(tid) && tid !== currentTab);
2712
+ const matchingTabs = allMatches.filter((tid) => tid !== currentTab);
2379
2713
  if (matchingTabs.length > 1) {
2380
2714
  consola.log(` ${entry.name} — multiple matching tabs, skipping`);
2381
2715
  results.push({
@@ -2420,7 +2754,7 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
2420
2754
  continue;
2421
2755
  }
2422
2756
  consola.log(` ${entry.name} → resuming ${resolvedSessionId.slice(0, 8)}… in existing tab`);
2423
- 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`;
2424
2758
  await adapter.sendInput(termBlock.blockid, cmd);
2425
2759
  await new Promise((r) => setTimeout(r, 500));
2426
2760
  results.push({
@@ -2439,7 +2773,7 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
2439
2773
  }
2440
2774
  if (dryRun) {
2441
2775
  const sid = resolvedSessionId ? `${resolvedSessionId.slice(0, 8)}…` : "fresh";
2442
- consola.log(` ${entry.name} → would spawn new tab in ${entry.dir} (${sid})`);
2776
+ consola.log(` ${entry.name} → would spawn new tab in ${resolvedDir} (${sid})`);
2443
2777
  results.push({
2444
2778
  name: entry.name,
2445
2779
  result: `dry run: spawn (${sid})`
@@ -2448,6 +2782,7 @@ async function runManifestMode(manifestPath, createMissing, dryRun) {
2448
2782
  }
2449
2783
  toSpawn.push({
2450
2784
  ...entry,
2785
+ dir: resolvedDir,
2451
2786
  session_id: resolvedSessionId
2452
2787
  });
2453
2788
  }
@@ -2503,7 +2838,9 @@ async function runLegacyMode(rawDir, dryRun) {
2503
2838
  const { tabsById, workspaces, tabNames } = await adapter.getAllData();
2504
2839
  const currentTab = adapter.currentTabId();
2505
2840
  const tabs = [];
2841
+ const originalOrder = [];
2506
2842
  for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
2843
+ originalOrder.push(tabId);
2507
2844
  if (tabId === currentTab) continue;
2508
2845
  const blocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
2509
2846
  if (!blocks.length) continue;
@@ -2587,8 +2924,20 @@ async function runLegacyMode(rawDir, dryRun) {
2587
2924
  await Promise.all(unknownTabs.map(async (r) => {
2588
2925
  emptyById.set(r.tab.tabId, await adapter.confirmScrollbackEmpty(r.tab.blockId));
2589
2926
  }));
2927
+ const claimedNames = /* @__PURE__ */ new Set();
2590
2928
  for (const r of resolved) {
2591
2929
  const { tab, sessionId, sessionDir } = r;
2930
+ if (claimedNames.has(tab.name)) {
2931
+ const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2932
+ for (const bid of blockIds) adapter.deleteBlock(bid);
2933
+ consola.log(` ${tab.name} — duplicate dead tab, closing (already restoring one)`);
2934
+ results.push({
2935
+ name: tab.name,
2936
+ result: "duplicate dead tab — closed"
2937
+ });
2938
+ continue;
2939
+ }
2940
+ claimedNames.add(tab.name);
2592
2941
  if (tab.status === "unknown" && emptyById.get(tab.tabId)) {
2593
2942
  const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2594
2943
  toRecreate.push({
@@ -2632,6 +2981,7 @@ async function runLegacyMode(rawDir, dryRun) {
2632
2981
  for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
2633
2982
  adapter.closeSocket();
2634
2983
  consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
2984
+ const recreatedIds = /* @__PURE__ */ new Map();
2635
2985
  const recreateOne = async (t) => {
2636
2986
  try {
2637
2987
  const newTabId = await openSession({
@@ -2640,6 +2990,7 @@ async function runLegacyMode(rawDir, dryRun) {
2640
2990
  claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`,
2641
2991
  tailDelayMs: 500
2642
2992
  });
2993
+ recreatedIds.set(t.tabId, newTabId);
2643
2994
  const r = results.find((x) => x.name === t.name);
2644
2995
  r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
2645
2996
  } catch (err) {
@@ -2652,6 +3003,14 @@ async function runLegacyMode(rawDir, dryRun) {
2652
3003
  await recreateOne(t);
2653
3004
  if (usesDirectSpawn) await new Promise((r) => setTimeout(r, SPAWN_SETTLE_MS));
2654
3005
  }
3006
+ if (recreatedIds.size && typeof adapter.reorderTabs === "function") {
3007
+ const desiredOrder = originalOrder.map((id) => recreatedIds.get(id) ?? id);
3008
+ try {
3009
+ await adapter.reorderTabs(desiredOrder);
3010
+ } catch (err) {
3011
+ consola.warn(`Could not restore tab order: ${err.message}`);
3012
+ }
3013
+ }
2655
3014
  } else adapter.closeSocket();
2656
3015
  console.log("\nRestore summary:");
2657
3016
  for (const r of results) console.log(` ${r.name}: ${r.result}`);
@@ -3421,6 +3780,86 @@ const importCommand = define({
3421
3780
  }
3422
3781
  });
3423
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
3424
3863
  //#region src/commands/index.ts
3425
3864
  const defaultCommand = define({
3426
3865
  name: "cctabs",
@@ -3447,7 +3886,8 @@ const subCommands = new Map([
3447
3886
  ["doctor", doctorCommand],
3448
3887
  ["install-tabby-plugin", installTabbyPluginCommand],
3449
3888
  ["export", exportCommand],
3450
- ["import", importCommand]
3889
+ ["import", importCommand],
3890
+ ["sort", sortCommand]
3451
3891
  ]);
3452
3892
  async function run() {
3453
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.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",
@@ -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
  ---
@@ -281,6 +281,18 @@ cctabs restore ~/Dev/myapp # restrict the search to one project dir
281
281
 
282
282
  If a session was started in a different `cwd` than the tab's current directory (common after `cd`-ing inside the tab), the global search still finds it via the recorded session metadata — no need to guess the right dir.
283
283
 
284
+ ### The "Resume from summary / full session" picker
285
+
286
+ When `claude --resume` reattaches a large or old session, Claude first shows a blocking picker:
287
+
288
+ ```
289
+ ❯ 1. Resume from summary (recommended)
290
+ 2. Resume full session as-is
291
+ 3. Don't ask me again
292
+ ```
293
+
294
+ **Always pick option 2, "Resume full session as-is."** The point of `restore` is to bring the conversation back intact — resuming from a summary discards the live context you're restoring for. `restore` auto-advances this picker for you (it moves down once to option 2 and confirms), so you normally never see it. If you ever do drive it manually (e.g. sending keys to a tab), send **↓ then Enter** — never the bare Enter that would accept the summary, and never option 3, which permanently silences the prompt in that session's config.
295
+
284
296
  ## Workflow: Moving sessions across machines
285
297
 
286
298
  Use `export` + `import` to migrate a tab (or a whole workspace) — and its underlying Claude conversation — from one machine to another, e.g. when switching laptops or sharing a debug session with a teammate.
@@ -346,13 +358,10 @@ cctabs send payments "yes\n" # quick replies
346
358
 
347
359
  ### Spawning gotchas (hard-won)
348
360
 
349
- 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:
350
362
  ```bash
351
- cctabs new kid ~/Dev/myapp --worktree -p "..."
352
- # Then in the ORCHESTRATOR tab:
353
363
  git -C ~/Dev/myapp/.claude/worktrees/kid log --oneline -1
354
364
  ```
355
- 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.
356
365
 
357
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:
358
367
  - Have the orchestrator handle rebases after the subagent is done.
@@ -388,9 +397,10 @@ echo "do the thing" | cctabs send auth # pipe via stdin
388
397
 
389
398
  ```bash
390
399
  cctabs new feature-name ~/Dev/myapp --worktree
391
- # Equivalent to: cd ~/Dev/myapp && claude --worktree "feature-name" --name "feature-name"
392
- # Claude creates: ~/Dev/myapp/.claude/worktrees/feature-name/
393
- # 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`.
394
404
  ```
395
405
 
396
406
  ### Existing branch — ask Claude to enter the worktree mid-session
@@ -414,7 +424,7 @@ cctabs new feature ~/Dev/myapp --worktree
414
424
 
415
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.
416
426
 
417
- **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.
418
428
 
419
429
  ## Handling `cctabs new` Timeout Errors
420
430