@generativereality/cctabs 0.1.2 → 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 +115 -35
- package/package.json +1 -1
- package/skills/cctabs/SKILL.md +13 -0
|
@@ -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,
|
|
@@ -763,6 +763,72 @@ function findSessionsByName(dir, name) {
|
|
|
763
763
|
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
764
764
|
}
|
|
765
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
|
+
/**
|
|
766
832
|
* List all unique session names (customTitle) in a project directory.
|
|
767
833
|
* Used to show available names when a resume lookup fails.
|
|
768
834
|
*/
|
|
@@ -1232,20 +1298,15 @@ const configCommand = define({
|
|
|
1232
1298
|
//#region src/commands/restore.ts
|
|
1233
1299
|
const restoreCommand = define({
|
|
1234
1300
|
name: "restore",
|
|
1235
|
-
description: "Resume Claude sessions in all terminal-state tabs (e.g. after a reboot)",
|
|
1236
|
-
args: {
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
dry: {
|
|
1242
|
-
type: "boolean",
|
|
1243
|
-
short: "n",
|
|
1244
|
-
description: "Show what would be resumed without actually doing it"
|
|
1245
|
-
}
|
|
1246
|
-
},
|
|
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
|
+
} },
|
|
1247
1307
|
async run(ctx) {
|
|
1248
|
-
const
|
|
1308
|
+
const rawDir = ctx.positionals[1];
|
|
1309
|
+
const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
|
|
1249
1310
|
const dryRun = ctx.values.dry;
|
|
1250
1311
|
const adapter = requireWaveAdapter();
|
|
1251
1312
|
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
@@ -1277,27 +1338,45 @@ const restoreCommand = define({
|
|
|
1277
1338
|
const results = [];
|
|
1278
1339
|
const toRecreate = [];
|
|
1279
1340
|
for (const tab of toResume) {
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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;
|
|
1296
1376
|
}
|
|
1297
|
-
const sessionId = sessions[0].id;
|
|
1298
1377
|
if (dryRun) {
|
|
1299
1378
|
const mode = tab.status === "unknown" ? "recreate" : "send";
|
|
1300
|
-
consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}
|
|
1379
|
+
consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}… in ${sessionDir}`);
|
|
1301
1380
|
results.push({
|
|
1302
1381
|
name: tab.name,
|
|
1303
1382
|
result: `dry run: ${sessionId.slice(0, 8)}…`
|
|
@@ -1310,6 +1389,7 @@ const restoreCommand = define({
|
|
|
1310
1389
|
toRecreate.push({
|
|
1311
1390
|
name: tab.name,
|
|
1312
1391
|
sessionId,
|
|
1392
|
+
sessionDir,
|
|
1313
1393
|
blockIds,
|
|
1314
1394
|
tabId: tab.tabId
|
|
1315
1395
|
});
|
|
@@ -1320,8 +1400,8 @@ const restoreCommand = define({
|
|
|
1320
1400
|
continue;
|
|
1321
1401
|
}
|
|
1322
1402
|
}
|
|
1323
|
-
consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… (send)`);
|
|
1324
|
-
const cmd = `cd ${JSON.stringify(
|
|
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`;
|
|
1325
1405
|
await adapter.sendInput(tab.blockId, cmd);
|
|
1326
1406
|
await new Promise((r) => setTimeout(r, 500));
|
|
1327
1407
|
results.push({
|
|
@@ -1350,7 +1430,7 @@ const restoreCommand = define({
|
|
|
1350
1430
|
for (const t of toRecreate) try {
|
|
1351
1431
|
const newTabId = await openSession({
|
|
1352
1432
|
tabName: t.name,
|
|
1353
|
-
dir,
|
|
1433
|
+
dir: t.sessionDir,
|
|
1354
1434
|
claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
|
|
1355
1435
|
});
|
|
1356
1436
|
const r = results.find((x) => x.name === t.name);
|
package/package.json
CHANGED
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -71,6 +71,7 @@ cctabs sessions # list all tabs with session status
|
|
|
71
71
|
cctabs list # list all workspaces, tabs, and blocks
|
|
72
72
|
cctabs new <name> [dir] [-w workspace] [-p "prompt"] [-f file] # new tab + claude
|
|
73
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)
|
|
74
75
|
cctabs fork <tab-name> [-n new-name] # fork session into new tab (--resume <id> --fork-session)
|
|
75
76
|
cctabs close <name-or-id> # close a tab
|
|
76
77
|
cctabs rename <name-or-id> <new-name> # rename a tab
|
|
@@ -126,6 +127,18 @@ cctabs resume api ~/Dev/myapp
|
|
|
126
127
|
**Use `cctabs resume` instead of `cctabs new` when you want to continue a previous conversation.**
|
|
127
128
|
`cctabs new` always starts a fresh Claude session. `cctabs resume` picks up where the last session left off.
|
|
128
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
|
+
|
|
129
142
|
## Workflow: Forking a Session
|
|
130
143
|
|
|
131
144
|
Use `fork` when you want to explore an alternative approach without disrupting the original.
|