@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +478 -38
- package/package.json +5 -2
- package/skills/cctabs/SKILL.md +20 -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",
|
|
@@ -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
|
|
1119
|
-
if (
|
|
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,
|
|
1473
|
+
await waitForScrollbackMatch(adapter, blockId, readySignal, "Claude prompt", 45e3);
|
|
1328
1474
|
} catch {
|
|
1329
|
-
|
|
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 <
|
|
1508
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
1364
1509
|
await adapter.sendInput(blockId, "\r");
|
|
1365
|
-
await sleep(
|
|
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
|
-
|
|
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 =
|
|
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 ($, %,
|
|
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
|
-
|
|
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
|
|
2212
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2375
|
-
if (
|
|
2376
|
-
|
|
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 =
|
|
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(
|
|
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 ${
|
|
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.
|
|
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
|
---
|
|
@@ -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. **
|
|
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
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
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
|
|
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
|
|