@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.
@@ -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.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.2";
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 workspaces = [];
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
- workspaces = await this.workspaceList();
337
+ rawWorkspaces = await this.workspaceList();
335
338
  } catch {} finally {
336
339
  this.closeSocket();
337
340
  }
338
- if (!workspaces.length) {
339
- const wsId = process.env.WAVETERM_WORKSPACEID ?? "";
340
- workspaces = [{
341
- workspacedata: {
342
- oid: wsId,
343
- name: wsId.slice(0, 8) || "default",
344
- tabids: [...tabsById.keys()]
345
- },
346
- windowid: ""
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) sessionId = explicitSession;
835
- else {
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
- 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
- },
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 dir = resolve((ctx.positionals[1] ?? process.cwd()).replace(/^~/, homedir()));
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
- 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;
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(dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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.