@generativereality/cctabs 0.1.1 → 0.1.3
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 +194 -48
- package/package.json +3 -3
- package/skills/cctabs/SKILL.md +110 -2
|
@@ -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.1.
|
|
4
|
+
"version": "0.1.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "generativereality",
|
|
7
7
|
"url": "https://cctabs.com"
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { consola } from "consola";
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
11
11
|
//#region package.json
|
|
12
12
|
var name = "@generativereality/cctabs";
|
|
13
|
-
var version = "0.1.
|
|
13
|
+
var version = "0.1.3";
|
|
14
14
|
var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
|
|
15
15
|
var package_default = {
|
|
16
16
|
name,
|
|
@@ -28,10 +28,10 @@ var package_default = {
|
|
|
28
28
|
"build": "tsdown",
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
30
|
"lint": "eslint src/",
|
|
31
|
-
"check": "
|
|
31
|
+
"check": "npm run typecheck && npm run build",
|
|
32
32
|
"release": "bumpp && npm publish",
|
|
33
33
|
"sync-plugin": "bash scripts/sync-plugin.sh",
|
|
34
|
-
"prepack": "bash scripts/sync-plugin.sh --check &&
|
|
34
|
+
"prepack": "bash scripts/sync-plugin.sh --check && npm run build"
|
|
35
35
|
},
|
|
36
36
|
keywords: [
|
|
37
37
|
"claude-code",
|
|
@@ -206,7 +206,9 @@ var WaveAdapter = class {
|
|
|
206
206
|
const out = execFileSync("wsh", [
|
|
207
207
|
"blocks",
|
|
208
208
|
"list",
|
|
209
|
-
"--json"
|
|
209
|
+
"--json",
|
|
210
|
+
"--timeout",
|
|
211
|
+
"15000"
|
|
210
212
|
], { encoding: "utf-8" });
|
|
211
213
|
return JSON.parse(out);
|
|
212
214
|
} catch {
|
|
@@ -222,6 +224,16 @@ var WaveAdapter = class {
|
|
|
222
224
|
`-${lastN}`
|
|
223
225
|
], { encoding: "utf-8" }).stdout ?? "";
|
|
224
226
|
}
|
|
227
|
+
/** Poll scrollback to confirm it really is empty (block has no live shell).
|
|
228
|
+
* A freshly-opened tab may briefly have empty scrollback before the prompt
|
|
229
|
+
* renders, so poll a few times before declaring the block dead. */
|
|
230
|
+
async confirmScrollbackEmpty(blockId, attempts = 3, intervalMs = 500) {
|
|
231
|
+
for (let i = 0; i < attempts; i++) {
|
|
232
|
+
if (this.scrollback(blockId, 10).trim()) return false;
|
|
233
|
+
if (i < attempts - 1) await sleep(intervalMs);
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
225
237
|
/** Detect whether a Claude session is running in a terminal block */
|
|
226
238
|
detectSessionStatus(blockId) {
|
|
227
239
|
const tail = this.scrollback(blockId, 10);
|
|
@@ -261,7 +273,7 @@ var WaveAdapter = class {
|
|
|
261
273
|
}
|
|
262
274
|
return true;
|
|
263
275
|
}
|
|
264
|
-
async waitForNewBlock(beforeIds, timeoutMs =
|
|
276
|
+
async waitForNewBlock(beforeIds, timeoutMs = 15e3) {
|
|
265
277
|
const deadline = Date.now() + timeoutMs;
|
|
266
278
|
while (Date.now() < deadline) {
|
|
267
279
|
await sleep(250);
|
|
@@ -343,9 +355,12 @@ var WaveAdapter = class {
|
|
|
343
355
|
}
|
|
344
356
|
resolveTab(query, tabsById, tabNames) {
|
|
345
357
|
const q = query.toLowerCase();
|
|
346
|
-
|
|
358
|
+
const ids = [...tabsById.keys()];
|
|
359
|
+
const exact = ids.filter((tid) => (tabNames.get(tid) ?? "").toLowerCase() === q);
|
|
360
|
+
if (exact.length > 0) return exact;
|
|
361
|
+
return ids.filter((tid) => {
|
|
347
362
|
const name = tabNames.get(tid) ?? "";
|
|
348
|
-
return
|
|
363
|
+
return tid.startsWith(query) || name.toLowerCase().startsWith(q);
|
|
349
364
|
});
|
|
350
365
|
}
|
|
351
366
|
resolveBlock(query, blocks) {
|
|
@@ -353,10 +368,11 @@ var WaveAdapter = class {
|
|
|
353
368
|
}
|
|
354
369
|
resolveWorkspace(workspaces, query) {
|
|
355
370
|
const q = query.toLowerCase();
|
|
356
|
-
|
|
371
|
+
const exact = workspaces.filter(({ workspacedata: wd }) => (wd.name ?? "").toLowerCase() === q);
|
|
372
|
+
return (exact.length > 0 ? exact : workspaces.filter(({ workspacedata: wd }) => {
|
|
357
373
|
const name = wd.name ?? "";
|
|
358
|
-
return
|
|
359
|
-
}).map((w) => ({
|
|
374
|
+
return wd.oid.startsWith(query) || name.toLowerCase().startsWith(q);
|
|
375
|
+
})).map((w) => ({
|
|
360
376
|
data: w.workspacedata,
|
|
361
377
|
windowId: w.windowid
|
|
362
378
|
}));
|
|
@@ -649,9 +665,11 @@ const newCommand = define({
|
|
|
649
665
|
});
|
|
650
666
|
//#endregion
|
|
651
667
|
//#region src/core/session.ts
|
|
652
|
-
/** Convert an absolute path to Claude's project slug
|
|
668
|
+
/** Convert an absolute path to Claude Code's project slug.
|
|
669
|
+
* Claude Code replaces any non-alphanumeric character (spaces, /, ., etc.) with '-'.
|
|
670
|
+
* Hyphens are preserved. Example: "/Users/me/Remember This" → "-Users-me-Remember-This". */
|
|
653
671
|
function pathToProjectSlug(dir) {
|
|
654
|
-
return resolve(dir).replace(/[
|
|
672
|
+
return resolve(dir).replace(/[^A-Za-z0-9-]/g, "-");
|
|
655
673
|
}
|
|
656
674
|
/** Find the most recent .jsonl session file in a Claude project directory */
|
|
657
675
|
function latestJsonlIn(projectDir) {
|
|
@@ -745,6 +763,72 @@ function findSessionsByName(dir, name) {
|
|
|
745
763
|
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
746
764
|
}
|
|
747
765
|
/**
|
|
766
|
+
* Like findSessionsByName, but searches every project directory under
|
|
767
|
+
* ~/.claude/projects. Each match carries the cwd recorded in the session.
|
|
768
|
+
* Used by `cctabs restore` so callers don't have to guess the right dir.
|
|
769
|
+
*/
|
|
770
|
+
function findSessionsByNameGlobally(name) {
|
|
771
|
+
const projectsRoot = join(homedir(), ".claude", "projects");
|
|
772
|
+
if (!existsSync(projectsRoot)) return [];
|
|
773
|
+
const matches = [];
|
|
774
|
+
for (const slug of readdirSync(projectsRoot)) {
|
|
775
|
+
const projectDir = join(projectsRoot, slug);
|
|
776
|
+
let isDir = false;
|
|
777
|
+
try {
|
|
778
|
+
isDir = statSync(projectDir).isDirectory();
|
|
779
|
+
} catch {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (!isDir) continue;
|
|
783
|
+
const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
|
|
784
|
+
for (const f of files) {
|
|
785
|
+
const fullPath = join(projectDir, f);
|
|
786
|
+
try {
|
|
787
|
+
const lines = readFileSync(fullPath, "utf-8").split("\n");
|
|
788
|
+
let currentTitle = "";
|
|
789
|
+
let cwd = "";
|
|
790
|
+
let firstPrompt = "";
|
|
791
|
+
let lastActivity = "";
|
|
792
|
+
for (const line of lines) {
|
|
793
|
+
if (!line.trim()) continue;
|
|
794
|
+
try {
|
|
795
|
+
const entry = JSON.parse(line);
|
|
796
|
+
if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
|
|
797
|
+
if (!cwd && typeof entry.cwd === "string") cwd = entry.cwd;
|
|
798
|
+
if (!firstPrompt && entry.type === "user" && entry.message?.content) {
|
|
799
|
+
const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
|
|
800
|
+
if (text.startsWith("<")) continue;
|
|
801
|
+
firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
|
|
802
|
+
if (text.length > 120) firstPrompt += "…";
|
|
803
|
+
}
|
|
804
|
+
if (entry.message?.role === "assistant" && entry.message?.content) {
|
|
805
|
+
const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
|
|
806
|
+
type: "text",
|
|
807
|
+
text: entry.message.content
|
|
808
|
+
}];
|
|
809
|
+
for (const p of parts) if (p.type === "text" && p.text?.trim()) {
|
|
810
|
+
lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
|
|
811
|
+
if (p.text.length > 120) lastActivity += "…";
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
} catch {}
|
|
815
|
+
}
|
|
816
|
+
if (currentTitle !== name || !cwd) continue;
|
|
817
|
+
const stat = statSync(fullPath);
|
|
818
|
+
matches.push({
|
|
819
|
+
id: basename(f, ".jsonl"),
|
|
820
|
+
mtime: stat.mtimeMs,
|
|
821
|
+
size: stat.size,
|
|
822
|
+
firstPrompt,
|
|
823
|
+
lastActivity,
|
|
824
|
+
dir: cwd
|
|
825
|
+
});
|
|
826
|
+
} catch {}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
748
832
|
* List all unique session names (customTitle) in a project directory.
|
|
749
833
|
* Used to show available names when a resume lookup fails.
|
|
750
834
|
*/
|
|
@@ -849,7 +933,8 @@ const resumeCommand = define({
|
|
|
849
933
|
process.exit(1);
|
|
850
934
|
}
|
|
851
935
|
const tabId = matchingTabs[0];
|
|
852
|
-
const
|
|
936
|
+
const blocks = tabsById.get(tabId) ?? [];
|
|
937
|
+
const termBlock = blocks.find((b) => b.view === "term");
|
|
853
938
|
if (!termBlock) {
|
|
854
939
|
consola.error(`No terminal block found in tab '${name}'`);
|
|
855
940
|
process.exit(1);
|
|
@@ -860,7 +945,20 @@ const resumeCommand = define({
|
|
|
860
945
|
consola.warn(`Claude is already running in tab "${name}" (${status}) — skipping resume`);
|
|
861
946
|
process.exit(0);
|
|
862
947
|
}
|
|
863
|
-
if (status === "unknown")
|
|
948
|
+
if (status === "unknown") {
|
|
949
|
+
if (await adapter.confirmScrollbackEmpty(termBlock.blockid)) {
|
|
950
|
+
consola.info(`Tab "${name}" has no live shell (empty scrollback) — recreating`);
|
|
951
|
+
for (const b of blocks) adapter.deleteBlock(b.blockid);
|
|
952
|
+
adapter.closeSocket();
|
|
953
|
+
const newTabId = await openSession({
|
|
954
|
+
tabName: name,
|
|
955
|
+
dir,
|
|
956
|
+
claudeCmd: `claude --resume ${sessionId} --name ${JSON.stringify(name)}`
|
|
957
|
+
});
|
|
958
|
+
consola.success(`Tab "${name}" [${newTabId.slice(0, 8)}] → claude --resume ${sessionId.slice(0, 8)}… at ${dir} (recreated)`);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
864
962
|
const extraFlags = loadConfig().claude.flags.join(" ");
|
|
865
963
|
const cmd = `cd ${JSON.stringify(dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(name)}\r`;
|
|
866
964
|
await adapter.sendInput(termBlock.blockid, cmd);
|
|
@@ -1200,20 +1298,15 @@ const configCommand = define({
|
|
|
1200
1298
|
//#region src/commands/restore.ts
|
|
1201
1299
|
const restoreCommand = define({
|
|
1202
1300
|
name: "restore",
|
|
1203
|
-
description: "Resume Claude sessions in all terminal-state tabs (e.g. after a reboot)",
|
|
1204
|
-
args: {
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
dry: {
|
|
1210
|
-
type: "boolean",
|
|
1211
|
-
short: "n",
|
|
1212
|
-
description: "Show what would be resumed without actually doing it"
|
|
1213
|
-
}
|
|
1214
|
-
},
|
|
1301
|
+
description: "Resume Claude sessions in all terminal-state tabs (e.g. after a reboot). Searches every Claude project dir by default; pass an explicit dir to scope the search.",
|
|
1302
|
+
args: { dry: {
|
|
1303
|
+
type: "boolean",
|
|
1304
|
+
short: "n",
|
|
1305
|
+
description: "Show what would be resumed without actually doing it"
|
|
1306
|
+
} },
|
|
1215
1307
|
async run(ctx) {
|
|
1216
|
-
const
|
|
1308
|
+
const rawDir = ctx.positionals[1];
|
|
1309
|
+
const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
|
|
1217
1310
|
const dryRun = ctx.values.dry;
|
|
1218
1311
|
const adapter = requireWaveAdapter();
|
|
1219
1312
|
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
@@ -1243,35 +1336,72 @@ const restoreCommand = define({
|
|
|
1243
1336
|
consola.info(`Found ${toResume.length} tab(s) to restore:`);
|
|
1244
1337
|
const extraFlags = loadConfig().claude.flags.join(" ");
|
|
1245
1338
|
const results = [];
|
|
1339
|
+
const toRecreate = [];
|
|
1246
1340
|
for (const tab of toResume) {
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1341
|
+
let sessionId = null;
|
|
1342
|
+
let sessionDir = null;
|
|
1343
|
+
if (scopedDir) {
|
|
1344
|
+
const sessions = findSessionsByName(scopedDir, tab.name);
|
|
1345
|
+
if (sessions.length === 0) {
|
|
1346
|
+
consola.log(` ${tab.name} — no session named "${tab.name}" found in ${scopedDir}, skipping`);
|
|
1347
|
+
results.push({
|
|
1348
|
+
name: tab.name,
|
|
1349
|
+
result: "no matching session"
|
|
1350
|
+
});
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
if (sessions.length > 1) {
|
|
1354
|
+
consola.log(` ${tab.name} — multiple sessions found, skipping (use cctabs resume --session to pick one)`);
|
|
1355
|
+
results.push({
|
|
1356
|
+
name: tab.name,
|
|
1357
|
+
result: "ambiguous (multiple sessions)"
|
|
1358
|
+
});
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
sessionId = sessions[0].id;
|
|
1362
|
+
sessionDir = scopedDir;
|
|
1363
|
+
} else {
|
|
1364
|
+
const sessions = findSessionsByNameGlobally(tab.name);
|
|
1365
|
+
if (sessions.length === 0) {
|
|
1366
|
+
consola.log(` ${tab.name} — no session named "${tab.name}" found in any project, skipping`);
|
|
1367
|
+
results.push({
|
|
1368
|
+
name: tab.name,
|
|
1369
|
+
result: "no matching session"
|
|
1370
|
+
});
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
if (sessions.length > 1) consola.log(` ${tab.name} — multiple sessions found across projects, picking newest (${sessions[0].dir})`);
|
|
1374
|
+
sessionId = sessions[0].id;
|
|
1375
|
+
sessionDir = sessions[0].dir;
|
|
1263
1376
|
}
|
|
1264
|
-
const sessionId = sessions[0].id;
|
|
1265
1377
|
if (dryRun) {
|
|
1266
|
-
|
|
1378
|
+
const mode = tab.status === "unknown" ? "recreate" : "send";
|
|
1379
|
+
consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}… in ${sessionDir}`);
|
|
1267
1380
|
results.push({
|
|
1268
1381
|
name: tab.name,
|
|
1269
1382
|
result: `dry run: ${sessionId.slice(0, 8)}…`
|
|
1270
1383
|
});
|
|
1271
1384
|
continue;
|
|
1272
1385
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1386
|
+
if (tab.status === "unknown") {
|
|
1387
|
+
if (await adapter.confirmScrollbackEmpty(tab.blockId)) {
|
|
1388
|
+
const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
|
|
1389
|
+
toRecreate.push({
|
|
1390
|
+
name: tab.name,
|
|
1391
|
+
sessionId,
|
|
1392
|
+
sessionDir,
|
|
1393
|
+
blockIds,
|
|
1394
|
+
tabId: tab.tabId
|
|
1395
|
+
});
|
|
1396
|
+
results.push({
|
|
1397
|
+
name: tab.name,
|
|
1398
|
+
result: "queued for recreate"
|
|
1399
|
+
});
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
|
|
1404
|
+
const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
|
|
1275
1405
|
await adapter.sendInput(tab.blockId, cmd);
|
|
1276
1406
|
await new Promise((r) => setTimeout(r, 500));
|
|
1277
1407
|
results.push({
|
|
@@ -1293,7 +1423,23 @@ const restoreCommand = define({
|
|
|
1293
1423
|
}
|
|
1294
1424
|
}
|
|
1295
1425
|
}
|
|
1296
|
-
|
|
1426
|
+
if (!dryRun && toRecreate.length) {
|
|
1427
|
+
for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
|
|
1428
|
+
adapter.closeSocket();
|
|
1429
|
+
consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
|
|
1430
|
+
for (const t of toRecreate) try {
|
|
1431
|
+
const newTabId = await openSession({
|
|
1432
|
+
tabName: t.name,
|
|
1433
|
+
dir: t.sessionDir,
|
|
1434
|
+
claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
|
|
1435
|
+
});
|
|
1436
|
+
const r = results.find((x) => x.name === t.name);
|
|
1437
|
+
r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
const r = results.find((x) => x.name === t.name);
|
|
1440
|
+
r.result = `✘ recreate failed: ${err.message}`;
|
|
1441
|
+
}
|
|
1442
|
+
} else adapter.closeSocket();
|
|
1297
1443
|
console.log("\nRestore summary:");
|
|
1298
1444
|
for (const r of results) console.log(` ${r.name}: ${r.result}`);
|
|
1299
1445
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@generativereality/cctabs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
"build": "tsdown",
|
|
17
17
|
"typecheck": "tsc --noEmit",
|
|
18
18
|
"lint": "eslint src/",
|
|
19
|
-
"check": "
|
|
19
|
+
"check": "npm run typecheck && npm run build",
|
|
20
20
|
"release": "bumpp && npm publish",
|
|
21
21
|
"sync-plugin": "bash scripts/sync-plugin.sh",
|
|
22
|
-
"prepack": "bash scripts/sync-plugin.sh --check &&
|
|
22
|
+
"prepack": "bash scripts/sync-plugin.sh --check && npm run build"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"claude-code",
|
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -7,6 +7,20 @@ You are managing Claude Code sessions using the `cctabs` CLI.
|
|
|
7
7
|
|
|
8
8
|
**Important:** "tabs" here means **terminal tabs** (e.g. Wave Terminal tabs), NOT browser tabs. Each terminal tab runs its own Claude Code session. This skill is for managing those terminal-based Claude Code sessions — not for browser automation.
|
|
9
9
|
|
|
10
|
+
## Before you spawn anything: is cctabs the right tool?
|
|
11
|
+
|
|
12
|
+
cctabs is excellent for:
|
|
13
|
+
- **Multiple human-driven sessions** on unrelated projects (check on a deploy here, draft a blog post there, monitor a long-running task somewhere else).
|
|
14
|
+
- **Genuinely orthogonal parallel work** where each tab touches a disjoint file set (e.g. each tab writes to its own new directory, or each tab works on a different repo).
|
|
15
|
+
- **Long-running background sessions** that the user wants to check on later (builds, scrapes, benchmarks).
|
|
16
|
+
|
|
17
|
+
cctabs is the WRONG tool for:
|
|
18
|
+
- **Interconnected parallel work within one session.** If you're orchestrating and farming out subtasks that all modify the same evolving codebase, tabs hide each other's commits from each other. By the time they're done, you have three diverged branches that need manual merge, and any intervening change on `main`/`next` can make the merge structurally painful. **Use the Agent tool instead** — subagents share your filesystem and git state, commit in place, and surface their result back to you.
|
|
19
|
+
- **Sequential dependencies.** If B depends on A's commits landing, don't parallelize — run A to completion first, then B.
|
|
20
|
+
- **Work that touches the same files as the current orchestrator session.** Commits race, branches diverge, conflicts multiply.
|
|
21
|
+
|
|
22
|
+
A good test: *"If both tabs finish successfully, will merging their output be trivial?"* If yes, cctabs is fine. If no (or you can't tell), do it sequentially or use subagents.
|
|
23
|
+
|
|
10
24
|
## First: Ensure cctabs is available
|
|
11
25
|
|
|
12
26
|
```bash
|
|
@@ -27,6 +41,29 @@ Do not modify PATH or npm configuration beyond this.
|
|
|
27
41
|
|
|
28
42
|
Each Claude Code session runs in its own **terminal tab**. `cctabs` lets you — and other Claude Code sessions — introspect and orchestrate the full session fleet.
|
|
29
43
|
|
|
44
|
+
## When to Use Worktrees
|
|
45
|
+
|
|
46
|
+
**Use `--worktree` whenever a tab will edit code on a branch that differs from the main working tree.** This includes:
|
|
47
|
+
- Fixing CI on a PR (`cctabs new fix-1789 ~/Dev/myapp --worktree`)
|
|
48
|
+
- Working on a feature branch while the main checkout runs a dev server
|
|
49
|
+
- Any task where multiple tabs might checkout different branches
|
|
50
|
+
|
|
51
|
+
Without `--worktree`, all tabs share the same working directory. If two tabs checkout different branches, they stomp on each other's files — causing silent conflicts, lost changes, and broken dev servers.
|
|
52
|
+
|
|
53
|
+
**Rule of thumb:**
|
|
54
|
+
- **Read-only / docs / coordination** → no worktree needed (stays on current branch)
|
|
55
|
+
- **Editing code on a different branch** → always `--worktree`
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# ❌ WRONG — two tabs checking out different branches in the same directory
|
|
59
|
+
cctabs new fix-auth ~/Dev/myapp --prompt "checkout PR #101 and fix lint"
|
|
60
|
+
cctabs new fix-api ~/Dev/myapp --prompt "checkout PR #102 and fix tests"
|
|
61
|
+
|
|
62
|
+
# ✅ RIGHT — each gets its own isolated copy
|
|
63
|
+
cctabs new fix-auth ~/Dev/myapp --worktree --prompt "checkout PR #101 and fix lint"
|
|
64
|
+
cctabs new fix-api ~/Dev/myapp --worktree --prompt "checkout PR #102 and fix tests"
|
|
65
|
+
```
|
|
66
|
+
|
|
30
67
|
## Quick Reference
|
|
31
68
|
|
|
32
69
|
```bash
|
|
@@ -34,6 +71,7 @@ cctabs sessions # list all tabs with session status
|
|
|
34
71
|
cctabs list # list all workspaces, tabs, and blocks
|
|
35
72
|
cctabs new <name> [dir] [-w workspace] [-p "prompt"] [-f file] # new tab + claude
|
|
36
73
|
cctabs resume <name> [dir] # resume last session (reuses tab or creates one)
|
|
74
|
+
cctabs restore [dir] [--dry] # resume every dead tab (e.g. after a reboot)
|
|
37
75
|
cctabs fork <tab-name> [-n new-name] # fork session into new tab (--resume <id> --fork-session)
|
|
38
76
|
cctabs close <name-or-id> # close a tab
|
|
39
77
|
cctabs rename <name-or-id> <new-name> # rename a tab
|
|
@@ -89,6 +127,18 @@ cctabs resume api ~/Dev/myapp
|
|
|
89
127
|
**Use `cctabs resume` instead of `cctabs new` when you want to continue a previous conversation.**
|
|
90
128
|
`cctabs new` always starts a fresh Claude session. `cctabs resume` picks up where the last session left off.
|
|
91
129
|
|
|
130
|
+
## Workflow: Restoring tabs after a reboot
|
|
131
|
+
|
|
132
|
+
After a Wave/computer restart, every tab loses its Claude session and shows up with `terminal` or `unknown` status. `cctabs restore` walks every such tab, looks up its session by name across **all** Claude project directories, and re-attaches in place.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
cctabs restore # search all projects (default)
|
|
136
|
+
cctabs restore --dry # preview what would be resumed without doing it
|
|
137
|
+
cctabs restore ~/Dev/myapp # restrict the search to one project dir
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
92
142
|
## Workflow: Forking a Session
|
|
93
143
|
|
|
94
144
|
Use `fork` when you want to explore an alternative approach without disrupting the original.
|
|
@@ -104,7 +154,9 @@ The forked session shares full conversation history up to the fork point, then d
|
|
|
104
154
|
|
|
105
155
|
## Workflow: Spawning a Parallel Agent
|
|
106
156
|
|
|
107
|
-
|
|
157
|
+
**Before spawning, re-read "is cctabs the right tool?" above.** If the task is interconnected with your current work, use the Agent tool (subagents) instead — they share your filesystem and commits.
|
|
158
|
+
|
|
159
|
+
As a Claude Code session, you can spawn a sibling session for a **genuinely independent** parallel task:
|
|
108
160
|
|
|
109
161
|
**Preferred: pass the initial task directly to `cctabs new`** using `--prompt` or `--file`. This polls internally until Claude's `❯` prompt appears before sending — no race condition:
|
|
110
162
|
|
|
@@ -125,6 +177,25 @@ cctabs send payments "yes\n" # quick replies
|
|
|
125
177
|
|
|
126
178
|
**Do NOT call `cctabs send` immediately after `cctabs new`** — Claude is still starting up and the text will land as raw shell commands.
|
|
127
179
|
|
|
180
|
+
### Spawning gotchas (hard-won)
|
|
181
|
+
|
|
182
|
+
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:
|
|
183
|
+
```bash
|
|
184
|
+
cctabs new kid ~/Dev/myapp --worktree -p "..."
|
|
185
|
+
# Then in the ORCHESTRATOR tab:
|
|
186
|
+
git -C ~/Dev/myapp/.claude/worktrees/kid log --oneline -1
|
|
187
|
+
```
|
|
188
|
+
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.
|
|
189
|
+
|
|
190
|
+
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:
|
|
191
|
+
- Have the orchestrator handle rebases after the subagent is done.
|
|
192
|
+
- Or send a precise patch/diff rather than a verbal rebase instruction.
|
|
193
|
+
- Or tell the subagent explicitly: *"do not rebase, do not reset; make fixup commits on top of your existing branch."*
|
|
194
|
+
|
|
195
|
+
3. **Subagents won't see each other's commits.** Each tab has its own working tree. If ws-A commits a schema, ws-B cannot consume it until you merge A → main → rebase B. This is a fundamental property, not a bug. Only parallelize when this limitation doesn't matter.
|
|
196
|
+
|
|
197
|
+
4. **Don't delegate rebases or merges to subagents.** Those are orchestrator work. Subagents produce content; orchestrator integrates.
|
|
198
|
+
|
|
128
199
|
## Workflow: Monitoring Another Session
|
|
129
200
|
|
|
130
201
|
```bash
|
|
@@ -176,10 +247,36 @@ cctabs new feature ~/Dev/myapp --worktree
|
|
|
176
247
|
|
|
177
248
|
**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.
|
|
178
249
|
|
|
250
|
+
**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.
|
|
251
|
+
|
|
252
|
+
## Handling `cctabs new` Timeout Errors
|
|
253
|
+
|
|
254
|
+
`cctabs new` may occasionally fail with "Timed out waiting for new terminal block". This does **NOT** mean you have too many tabs or that Wave Terminal has hit a limit.
|
|
255
|
+
|
|
256
|
+
**Possible causes** (root cause not yet confirmed):
|
|
257
|
+
- Wave Terminal may need to be in focus / foreground for tab creation to register
|
|
258
|
+
- The internal timeout may be slightly too short for the current system load
|
|
259
|
+
- Transient IPC timing issue between cctabs and Wave
|
|
260
|
+
|
|
261
|
+
**What to do:**
|
|
262
|
+
1. **Retry the same command** — it often works on the second attempt
|
|
263
|
+
2. If it fails again, wait a few seconds and retry once more
|
|
264
|
+
3. If it keeps failing, ask the user to bring Wave Terminal to the foreground and try again
|
|
265
|
+
|
|
266
|
+
**What NOT to do:**
|
|
267
|
+
- ❌ Do NOT assume there is a "tab limit" — there isn't one
|
|
268
|
+
- ❌ Do NOT close other tabs to "make room" — this destroys the user's sessions
|
|
269
|
+
- ❌ Do NOT suggest the user has too many tabs open
|
|
270
|
+
|
|
179
271
|
## Workflow: Cleanup
|
|
180
272
|
|
|
273
|
+
**⚠️ NEVER close tabs without explicit user approval.** Each tab may contain an active session with important context, uncommitted work, or in-progress tasks. Closing a tab is destructive and irreversible.
|
|
274
|
+
|
|
275
|
+
**Always ask first:**
|
|
276
|
+
> "These tabs look idle: `old-feature`, `fix-1234`. Want me to close any of them?"
|
|
277
|
+
|
|
278
|
+
Only after the user confirms:
|
|
181
279
|
```bash
|
|
182
|
-
cctabs sessions # find idle/terminal tabs
|
|
183
280
|
cctabs close old-feature # close by name (prefix match)
|
|
184
281
|
cctabs close e5f6a7b8 # close by block ID prefix
|
|
185
282
|
```
|
|
@@ -200,3 +297,14 @@ Name tabs after the **project or task**:
|
|
|
200
297
|
- `cctabs new` and `cctabs resume` automatically pass `--name <tab-name>` to claude, syncing the session display name with the tab title
|
|
201
298
|
- Configured `claude.flags` in `~/.config/cctabs/config.toml` are applied to every session
|
|
202
299
|
- `cctabs send` resolves tab names to their terminal block automatically
|
|
300
|
+
|
|
301
|
+
## Lesson: the common failure mode
|
|
302
|
+
|
|
303
|
+
A pattern that wastes the most tokens: an orchestrator spawns three tabs for "parallel workstreams" on the same feature, each tab diverges from the base and from each other, the orchestrator loses visibility into what each is doing, one tab misinterprets a course-correct and resets its own work, and finally the orchestrator spends hours hand-merging commits that don't apply cleanly against an intervening refactor.
|
|
304
|
+
|
|
305
|
+
The fix is upstream: before spawning, ask *"are these workstreams actually independent?"* If the answer is "mostly, but they share a common data model / schema / utility module" — they are **not** independent for cctabs purposes. Either:
|
|
306
|
+
- Do them sequentially in one tab (cheapest).
|
|
307
|
+
- Use the Agent tool for subtasks that share orchestrator state.
|
|
308
|
+
- Land the shared pieces first on `main`/`next`, push, then spawn tabs (each branches cleanly off the new tip and work is truly orthogonal from there).
|
|
309
|
+
|
|
310
|
+
Parallel tabs earn their keep when the work is genuinely orthogonal (separate repos, separate brand-new directories, independent features) and when you'd otherwise be idle waiting for one long-running task to finish.
|