@generativereality/cctabs 0.1.2 → 0.1.4
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 +189 -57
- 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.4",
|
|
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.4";
|
|
14
14
|
var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
|
|
15
15
|
var package_default = {
|
|
16
16
|
name,
|
|
@@ -202,14 +202,17 @@ var WaveAdapter = class {
|
|
|
202
202
|
this.jwt = process.env.WAVETERM_JWT ?? "";
|
|
203
203
|
}
|
|
204
204
|
blocksList() {
|
|
205
|
+
const wsId = process.env.WAVETERM_WORKSPACEID ?? "";
|
|
206
|
+
const args = [
|
|
207
|
+
"blocks",
|
|
208
|
+
"list",
|
|
209
|
+
"--json",
|
|
210
|
+
"--timeout",
|
|
211
|
+
"15000"
|
|
212
|
+
];
|
|
213
|
+
if (wsId) args.push("--workspace", wsId);
|
|
205
214
|
try {
|
|
206
|
-
const out = execFileSync("wsh",
|
|
207
|
-
"blocks",
|
|
208
|
-
"list",
|
|
209
|
-
"--json",
|
|
210
|
-
"--timeout",
|
|
211
|
-
"15000"
|
|
212
|
-
], { encoding: "utf-8" });
|
|
215
|
+
const out = execFileSync("wsh", args, { encoding: "utf-8" });
|
|
213
216
|
return JSON.parse(out);
|
|
214
217
|
} catch {
|
|
215
218
|
return [];
|
|
@@ -325,27 +328,37 @@ var WaveAdapter = class {
|
|
|
325
328
|
tabsById.set(b.tabid, arr);
|
|
326
329
|
}
|
|
327
330
|
const tabNames = /* @__PURE__ */ new Map();
|
|
328
|
-
let
|
|
331
|
+
let rawWorkspaces = [];
|
|
329
332
|
try {
|
|
330
333
|
for (const tabId of tabsById.keys()) {
|
|
331
334
|
const td = await this.getTab(tabId);
|
|
332
335
|
tabNames.set(tabId, td.name ?? tabId.slice(0, 8));
|
|
333
336
|
}
|
|
334
|
-
|
|
337
|
+
rawWorkspaces = await this.workspaceList();
|
|
335
338
|
} catch {} finally {
|
|
336
339
|
this.closeSocket();
|
|
337
340
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
341
|
+
const currentWsId = process.env.WAVETERM_WORKSPACEID ?? "";
|
|
342
|
+
const tabIdsHere = [...tabsById.keys()];
|
|
343
|
+
const existing = rawWorkspaces.find((w) => w.workspacedata.oid === currentWsId);
|
|
344
|
+
const workspaces = [];
|
|
345
|
+
if (currentWsId) workspaces.push({
|
|
346
|
+
workspacedata: {
|
|
347
|
+
oid: currentWsId,
|
|
348
|
+
name: existing?.workspacedata.name ?? (currentWsId.slice(0, 8) || "current"),
|
|
349
|
+
tabids: tabIdsHere
|
|
350
|
+
},
|
|
351
|
+
windowid: existing?.windowid ?? ""
|
|
352
|
+
});
|
|
353
|
+
for (const ws of rawWorkspaces) if (ws.workspacedata.oid !== currentWsId) workspaces.push(ws);
|
|
354
|
+
if (!workspaces.length) workspaces.push({
|
|
355
|
+
workspacedata: {
|
|
356
|
+
oid: "",
|
|
357
|
+
name: "default",
|
|
358
|
+
tabids: tabIdsHere
|
|
359
|
+
},
|
|
360
|
+
windowid: ""
|
|
361
|
+
});
|
|
349
362
|
return {
|
|
350
363
|
blocks,
|
|
351
364
|
tabsById,
|
|
@@ -763,6 +776,72 @@ function findSessionsByName(dir, name) {
|
|
|
763
776
|
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
764
777
|
}
|
|
765
778
|
/**
|
|
779
|
+
* Like findSessionsByName, but searches every project directory under
|
|
780
|
+
* ~/.claude/projects. Each match carries the cwd recorded in the session.
|
|
781
|
+
* Used by `cctabs restore` so callers don't have to guess the right dir.
|
|
782
|
+
*/
|
|
783
|
+
function findSessionsByNameGlobally(name) {
|
|
784
|
+
const projectsRoot = join(homedir(), ".claude", "projects");
|
|
785
|
+
if (!existsSync(projectsRoot)) return [];
|
|
786
|
+
const matches = [];
|
|
787
|
+
for (const slug of readdirSync(projectsRoot)) {
|
|
788
|
+
const projectDir = join(projectsRoot, slug);
|
|
789
|
+
let isDir = false;
|
|
790
|
+
try {
|
|
791
|
+
isDir = statSync(projectDir).isDirectory();
|
|
792
|
+
} catch {
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (!isDir) continue;
|
|
796
|
+
const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
|
|
797
|
+
for (const f of files) {
|
|
798
|
+
const fullPath = join(projectDir, f);
|
|
799
|
+
try {
|
|
800
|
+
const lines = readFileSync(fullPath, "utf-8").split("\n");
|
|
801
|
+
let currentTitle = "";
|
|
802
|
+
let cwd = "";
|
|
803
|
+
let firstPrompt = "";
|
|
804
|
+
let lastActivity = "";
|
|
805
|
+
for (const line of lines) {
|
|
806
|
+
if (!line.trim()) continue;
|
|
807
|
+
try {
|
|
808
|
+
const entry = JSON.parse(line);
|
|
809
|
+
if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
|
|
810
|
+
if (!cwd && typeof entry.cwd === "string") cwd = entry.cwd;
|
|
811
|
+
if (!firstPrompt && entry.type === "user" && entry.message?.content) {
|
|
812
|
+
const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
|
|
813
|
+
if (text.startsWith("<")) continue;
|
|
814
|
+
firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
|
|
815
|
+
if (text.length > 120) firstPrompt += "…";
|
|
816
|
+
}
|
|
817
|
+
if (entry.message?.role === "assistant" && entry.message?.content) {
|
|
818
|
+
const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
|
|
819
|
+
type: "text",
|
|
820
|
+
text: entry.message.content
|
|
821
|
+
}];
|
|
822
|
+
for (const p of parts) if (p.type === "text" && p.text?.trim()) {
|
|
823
|
+
lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
|
|
824
|
+
if (p.text.length > 120) lastActivity += "…";
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
} catch {}
|
|
828
|
+
}
|
|
829
|
+
if (currentTitle !== name || !cwd) continue;
|
|
830
|
+
const stat = statSync(fullPath);
|
|
831
|
+
matches.push({
|
|
832
|
+
id: basename(f, ".jsonl"),
|
|
833
|
+
mtime: stat.mtimeMs,
|
|
834
|
+
size: stat.size,
|
|
835
|
+
firstPrompt,
|
|
836
|
+
lastActivity,
|
|
837
|
+
dir: cwd
|
|
838
|
+
});
|
|
839
|
+
} catch {}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return matches.sort((a, b) => b.mtime - a.mtime);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
766
845
|
* List all unique session names (customTitle) in a project directory.
|
|
767
846
|
* Used to show available names when a resume lookup fails.
|
|
768
847
|
*/
|
|
@@ -790,6 +869,39 @@ function listSessionNames(dir) {
|
|
|
790
869
|
}
|
|
791
870
|
return results.sort((a, b) => b.mtime - a.mtime);
|
|
792
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* Resolve a session ID prefix (e.g. "19aae7b4") to the full UUID by scanning
|
|
874
|
+
* `~/.claude/projects/`. Returns the input unchanged if it already looks like
|
|
875
|
+
* a full UUID, or null if no unique match exists. Pass `dir` to scope the
|
|
876
|
+
* search to one project; otherwise every project is checked.
|
|
877
|
+
*
|
|
878
|
+
* `claude --resume <prefix>` does NOT accept truncated IDs — it treats them
|
|
879
|
+
* as a search query and shows the picker. So callers must expand prefixes
|
|
880
|
+
* before forwarding to claude.
|
|
881
|
+
*/
|
|
882
|
+
function expandSessionId(input, dir) {
|
|
883
|
+
if (!input) return null;
|
|
884
|
+
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input)) return input;
|
|
885
|
+
const projectsRoot = join(homedir(), ".claude", "projects");
|
|
886
|
+
if (!existsSync(projectsRoot)) return null;
|
|
887
|
+
const projectDirs = dir ? [join(projectsRoot, pathToProjectSlug(dir))] : readdirSync(projectsRoot).map((d) => join(projectsRoot, d)).filter((p) => {
|
|
888
|
+
try {
|
|
889
|
+
return statSync(p).isDirectory();
|
|
890
|
+
} catch {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
const matches = [];
|
|
895
|
+
for (const pd of projectDirs) {
|
|
896
|
+
if (!existsSync(pd)) continue;
|
|
897
|
+
for (const f of readdirSync(pd)) {
|
|
898
|
+
if (extname(f) !== ".jsonl") continue;
|
|
899
|
+
const id = basename(f, ".jsonl");
|
|
900
|
+
if (id.startsWith(input) && !matches.includes(id)) matches.push(id);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return matches.length === 1 ? matches[0] : null;
|
|
904
|
+
}
|
|
793
905
|
//#endregion
|
|
794
906
|
//#region src/commands/resume.ts
|
|
795
907
|
function formatAge(mtimeMs) {
|
|
@@ -831,8 +943,14 @@ const resumeCommand = define({
|
|
|
831
943
|
}
|
|
832
944
|
const explicitSession = ctx.values.session;
|
|
833
945
|
let sessionId;
|
|
834
|
-
if (explicitSession)
|
|
835
|
-
|
|
946
|
+
if (explicitSession) {
|
|
947
|
+
const expanded = expandSessionId(explicitSession, dir) ?? expandSessionId(explicitSession);
|
|
948
|
+
if (!expanded) {
|
|
949
|
+
consola.error(`Session '${explicitSession}' not found (or matches multiple sessions). Pass the full UUID.`);
|
|
950
|
+
process.exit(1);
|
|
951
|
+
}
|
|
952
|
+
sessionId = expanded;
|
|
953
|
+
} else {
|
|
836
954
|
const sessions = findSessionsByName(dir, name);
|
|
837
955
|
if (sessions.length === 0) {
|
|
838
956
|
consola.error(`No session named "${name}" in ${dir}`);
|
|
@@ -1232,20 +1350,15 @@ const configCommand = define({
|
|
|
1232
1350
|
//#region src/commands/restore.ts
|
|
1233
1351
|
const restoreCommand = define({
|
|
1234
1352
|
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
|
-
},
|
|
1353
|
+
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.",
|
|
1354
|
+
args: { dry: {
|
|
1355
|
+
type: "boolean",
|
|
1356
|
+
short: "n",
|
|
1357
|
+
description: "Show what would be resumed without actually doing it"
|
|
1358
|
+
} },
|
|
1247
1359
|
async run(ctx) {
|
|
1248
|
-
const
|
|
1360
|
+
const rawDir = ctx.positionals[1];
|
|
1361
|
+
const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
|
|
1249
1362
|
const dryRun = ctx.values.dry;
|
|
1250
1363
|
const adapter = requireWaveAdapter();
|
|
1251
1364
|
const { tabsById, workspaces, tabNames } = await adapter.getAllData();
|
|
@@ -1277,27 +1390,45 @@ const restoreCommand = define({
|
|
|
1277
1390
|
const results = [];
|
|
1278
1391
|
const toRecreate = [];
|
|
1279
1392
|
for (const tab of toResume) {
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1393
|
+
let sessionId = null;
|
|
1394
|
+
let sessionDir = null;
|
|
1395
|
+
if (scopedDir) {
|
|
1396
|
+
const sessions = findSessionsByName(scopedDir, tab.name);
|
|
1397
|
+
if (sessions.length === 0) {
|
|
1398
|
+
consola.log(` ${tab.name} — no session named "${tab.name}" found in ${scopedDir}, skipping`);
|
|
1399
|
+
results.push({
|
|
1400
|
+
name: tab.name,
|
|
1401
|
+
result: "no matching session"
|
|
1402
|
+
});
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
if (sessions.length > 1) {
|
|
1406
|
+
consola.log(` ${tab.name} — multiple sessions found, skipping (use cctabs resume --session to pick one)`);
|
|
1407
|
+
results.push({
|
|
1408
|
+
name: tab.name,
|
|
1409
|
+
result: "ambiguous (multiple sessions)"
|
|
1410
|
+
});
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
sessionId = sessions[0].id;
|
|
1414
|
+
sessionDir = scopedDir;
|
|
1415
|
+
} else {
|
|
1416
|
+
const sessions = findSessionsByNameGlobally(tab.name);
|
|
1417
|
+
if (sessions.length === 0) {
|
|
1418
|
+
consola.log(` ${tab.name} — no session named "${tab.name}" found in any project, skipping`);
|
|
1419
|
+
results.push({
|
|
1420
|
+
name: tab.name,
|
|
1421
|
+
result: "no matching session"
|
|
1422
|
+
});
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
if (sessions.length > 1) consola.log(` ${tab.name} — multiple sessions found across projects, picking newest (${sessions[0].dir})`);
|
|
1426
|
+
sessionId = sessions[0].id;
|
|
1427
|
+
sessionDir = sessions[0].dir;
|
|
1296
1428
|
}
|
|
1297
|
-
const sessionId = sessions[0].id;
|
|
1298
1429
|
if (dryRun) {
|
|
1299
1430
|
const mode = tab.status === "unknown" ? "recreate" : "send";
|
|
1300
|
-
consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}
|
|
1431
|
+
consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}… in ${sessionDir}`);
|
|
1301
1432
|
results.push({
|
|
1302
1433
|
name: tab.name,
|
|
1303
1434
|
result: `dry run: ${sessionId.slice(0, 8)}…`
|
|
@@ -1310,6 +1441,7 @@ const restoreCommand = define({
|
|
|
1310
1441
|
toRecreate.push({
|
|
1311
1442
|
name: tab.name,
|
|
1312
1443
|
sessionId,
|
|
1444
|
+
sessionDir,
|
|
1313
1445
|
blockIds,
|
|
1314
1446
|
tabId: tab.tabId
|
|
1315
1447
|
});
|
|
@@ -1320,8 +1452,8 @@ const restoreCommand = define({
|
|
|
1320
1452
|
continue;
|
|
1321
1453
|
}
|
|
1322
1454
|
}
|
|
1323
|
-
consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… (send)`);
|
|
1324
|
-
const cmd = `cd ${JSON.stringify(
|
|
1455
|
+
consola.log(` ${tab.name} → resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
|
|
1456
|
+
const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
|
|
1325
1457
|
await adapter.sendInput(tab.blockId, cmd);
|
|
1326
1458
|
await new Promise((r) => setTimeout(r, 500));
|
|
1327
1459
|
results.push({
|
|
@@ -1350,7 +1482,7 @@ const restoreCommand = define({
|
|
|
1350
1482
|
for (const t of toRecreate) try {
|
|
1351
1483
|
const newTabId = await openSession({
|
|
1352
1484
|
tabName: t.name,
|
|
1353
|
-
dir,
|
|
1485
|
+
dir: t.sessionDir,
|
|
1354
1486
|
claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
|
|
1355
1487
|
});
|
|
1356
1488
|
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.
|