@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.
@@ -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.2",
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.2";
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
- dir: {
1238
- type: "positional",
1239
- description: "Working directory (default: cwd)"
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 dir = resolve((ctx.positionals[1] ?? process.cwd()).replace(/^~/, homedir()));
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
- const sessions = findSessionsByName(dir, tab.name);
1281
- if (sessions.length === 0) {
1282
- consola.log(` ${tab.name} — no session named "${tab.name}" found, skipping`);
1283
- results.push({
1284
- name: tab.name,
1285
- result: "no matching session"
1286
- });
1287
- continue;
1288
- }
1289
- if (sessions.length > 1) {
1290
- consola.log(` ${tab.name} — multiple sessions found, skipping (use cctabs resume --session to pick one)`);
1291
- results.push({
1292
- name: tab.name,
1293
- result: "ambiguous (multiple sessions)"
1294
- });
1295
- continue;
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(dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.1.2",
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": {
@@ -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.