@generativereality/cctabs 0.3.0 → 0.3.1

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.3.0",
4
+ "version": "0.3.1",
5
5
  "author": {
6
6
  "name": "generativereality",
7
7
  "url": "https://cctabs.com"
package/README.md CHANGED
@@ -67,15 +67,17 @@ curl -fsSL https://raw.githubusercontent.com/generativereality/cctabs/main/skill
67
67
  ## Usage
68
68
 
69
69
  ```
70
- cctabs sessions what's running (active/idle status)
70
+ cctabs sessions [--json] what's running (active/idle status)
71
71
  cctabs list all workspaces, tabs, and blocks
72
72
  cctabs new <name> [dir] [-w workspace] open tab, start claude
73
+ cctabs new <name> [dir] -r <session-id> open tab, resume an existing session by ID
73
74
  cctabs resume <name> [dir] open tab, run claude --continue
74
75
  cctabs fork <tab> [-n new-name] fork a session into a new tab
75
76
  cctabs close <tab> close a tab
76
77
  cctabs rename <tab> <new-name> rename a tab
77
78
  cctabs scrollback <tab> [lines] read terminal output (default: 50 lines)
78
- cctabs send <tab> [text] send input — arg, --file, or stdin pipe
79
+ cctabs send <tab> [text] [--wait-for-prompt] send input — arg, --file, or stdin pipe
80
+ cctabs restore [--manifest <file|->] [--create-missing] reattach or spawn from a manifest
79
81
  cctabs config show config path and values
80
82
  ```
81
83
 
@@ -114,6 +116,31 @@ cctabs scrollback auth # last 50 lines
114
116
  cctabs scrollback auth 200 # last 200 lines
115
117
  ```
116
118
 
119
+ ### Resume a specific session in a new tab
120
+
121
+ ```bash
122
+ # Useful when multiple sessions share the same dir — pass the exact session ID
123
+ cctabs new auth ~/Dev/myapp -r 19aae7b4-1234-…
124
+
125
+ # Combines with --worktree to resume inside an existing worktree
126
+ cctabs new auth ~/Dev/myapp -W -r 19aae7b4-…
127
+ ```
128
+
129
+ `cctabs resume <name>` is the right tool when there's only one session for a dir. Use `cctabs new ... --resume` when you need to disambiguate by session ID.
130
+
131
+ ### Migrate a fleet between terminals
132
+
133
+ ```bash
134
+ # On the source terminal, dump everything as a manifest
135
+ cctabs sessions --json > /tmp/fleet.json
136
+
137
+ # On the destination terminal, attach to any existing tabs and spawn the rest
138
+ cctabs restore --manifest /tmp/fleet.json --create-missing
139
+
140
+ # Or pipe directly
141
+ cctabs sessions --json | cctabs restore --manifest - --create-missing
142
+ ```
143
+
117
144
  ### Fork a session
118
145
 
119
146
  ```bash
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { consola } from "consola";
11
11
  import * as p from "@clack/prompts";
12
12
  //#region package.json
13
13
  var name = "@generativereality/cctabs";
14
- var version = "0.3.0";
14
+ var version = "0.3.1";
15
15
  var description = "Claude Code tab manager. Terminal tabs as the UI, no tmux.";
16
16
  var package_default = {
17
17
  name,
@@ -641,17 +641,28 @@ var TabbyAdapter = class {
641
641
  return true;
642
642
  }
643
643
  detectSessionStatus(blockId) {
644
- const tail = this.scrollback(blockId, 10);
644
+ const tail = this.scrollback(blockId, 200);
645
645
  if (!tail.trim()) return "unknown";
646
646
  const lastLine = tail.split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
647
647
  if (/[$%>]\s*$/.test(lastLine) && !lastLine.includes("claude")) return "terminal";
648
+ const compact = tail.replace(/\s+/g, "");
648
649
  if ([
649
650
  "Claude Code",
650
651
  "claude.ai/code",
651
- " Thinking",
652
- " Hatching",
653
- "⏵⏵ bypass"
654
- ].some((s) => tail.includes(s))) return "active";
652
+ "⏵⏵ bypass",
653
+ "⏵⏵ auto",
654
+ "Thinking",
655
+ "Hatching",
656
+ "Composing",
657
+ "Cogitating",
658
+ "Befuddling",
659
+ "Worked for",
660
+ "Baked for",
661
+ "Churned for",
662
+ "Cooked for",
663
+ "high effort"
664
+ ].some((m) => compact.includes(m.replace(/\s+/g, "")))) return "active";
665
+ if (/[✻✽✶✳✢]/.test(tail)) return "active";
655
666
  if (lastLine.toLowerCase().includes("claude")) return "idle";
656
667
  return "terminal";
657
668
  }
@@ -819,16 +830,288 @@ function requireAdapter() {
819
830
  process.exit(1);
820
831
  }
821
832
  //#endregion
833
+ //#region src/core/session.ts
834
+ /** Convert an absolute path to Claude Code's project slug.
835
+ * Claude Code replaces any non-alphanumeric character (spaces, /, ., etc.) with '-'.
836
+ * Hyphens are preserved. Example: "/Users/me/Remember This" → "-Users-me-Remember-This". */
837
+ function pathToProjectSlug(dir) {
838
+ return resolve(dir).replace(/[^A-Za-z0-9-]/g, "-");
839
+ }
840
+ /** Find the most recent .jsonl session file in a Claude project directory */
841
+ function latestJsonlIn(projectDir) {
842
+ if (!existsSync(projectDir)) return null;
843
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl").map((f) => ({
844
+ name: f,
845
+ mtime: statSync(join(projectDir, f)).mtimeMs
846
+ })).sort((a, b) => b.mtime - a.mtime);
847
+ return files.length ? basename(files[0].name, ".jsonl") : null;
848
+ }
849
+ /**
850
+ * Find the most recent Claude Code session ID for a directory.
851
+ * Also checks worktree subdirectories (.claude/worktrees/*) since tabs
852
+ * opened with --worktree run from a worktree path, not the repo root.
853
+ */
854
+ function findLatestSessionId(dir) {
855
+ const projectsRoot = join(homedir(), ".claude", "projects");
856
+ const direct = latestJsonlIn(join(projectsRoot, pathToProjectSlug(dir)));
857
+ if (direct) return direct;
858
+ const worktreesDir = join(dir, ".claude", "worktrees");
859
+ if (existsSync(worktreesDir)) {
860
+ const candidates = [];
861
+ for (const entry of readdirSync(worktreesDir)) {
862
+ const projectDir = join(projectsRoot, pathToProjectSlug(join(worktreesDir, entry)));
863
+ const id = latestJsonlIn(projectDir);
864
+ if (id) {
865
+ const mtime = statSync(join(projectDir, id + ".jsonl")).mtimeMs;
866
+ candidates.push({
867
+ id,
868
+ mtime
869
+ });
870
+ }
871
+ }
872
+ if (candidates.length) {
873
+ candidates.sort((a, b) => b.mtime - a.mtime);
874
+ return candidates[0].id;
875
+ }
876
+ }
877
+ return null;
878
+ }
879
+ /**
880
+ * Find all sessions with a given custom title (--name).
881
+ * Returns them sorted by most recent first, with the first user prompt for context.
882
+ */
883
+ function findSessionsByName(dir, name) {
884
+ const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
885
+ if (!existsSync(projectDir)) return [];
886
+ const matches = [];
887
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
888
+ for (const f of files) {
889
+ const fullPath = join(projectDir, f);
890
+ try {
891
+ const lines = readFileSync(fullPath, "utf-8").split("\n");
892
+ let currentTitle = "";
893
+ let firstPrompt = "";
894
+ let lastActivity = "";
895
+ for (const line of lines) {
896
+ if (!line.trim()) continue;
897
+ try {
898
+ const entry = JSON.parse(line);
899
+ if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
900
+ if (!firstPrompt && entry.type === "user" && entry.message?.content) {
901
+ const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
902
+ if (text.startsWith("<")) continue;
903
+ firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
904
+ if (text.length > 120) firstPrompt += "…";
905
+ }
906
+ if (entry.message?.role === "assistant" && entry.message?.content) {
907
+ const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
908
+ type: "text",
909
+ text: entry.message.content
910
+ }];
911
+ for (const p of parts) if (p.type === "text" && p.text?.trim()) {
912
+ lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
913
+ if (p.text.length > 120) lastActivity += "…";
914
+ }
915
+ }
916
+ } catch {}
917
+ }
918
+ if (currentTitle !== name) continue;
919
+ const stat = statSync(fullPath);
920
+ matches.push({
921
+ id: basename(f, ".jsonl"),
922
+ mtime: stat.mtimeMs,
923
+ size: stat.size,
924
+ firstPrompt,
925
+ lastActivity
926
+ });
927
+ } catch {}
928
+ }
929
+ return matches.sort((a, b) => b.mtime - a.mtime);
930
+ }
931
+ /**
932
+ * Like findSessionsByName, but searches every project directory under
933
+ * ~/.claude/projects. Each match carries the cwd recorded in the session.
934
+ * Used by `cctabs restore` so callers don't have to guess the right dir.
935
+ */
936
+ function findSessionsByNameGlobally(name) {
937
+ const projectsRoot = join(homedir(), ".claude", "projects");
938
+ if (!existsSync(projectsRoot)) return [];
939
+ const matches = [];
940
+ for (const slug of readdirSync(projectsRoot)) {
941
+ const projectDir = join(projectsRoot, slug);
942
+ let isDir = false;
943
+ try {
944
+ isDir = statSync(projectDir).isDirectory();
945
+ } catch {
946
+ continue;
947
+ }
948
+ if (!isDir) continue;
949
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
950
+ for (const f of files) {
951
+ const fullPath = join(projectDir, f);
952
+ try {
953
+ const lines = readFileSync(fullPath, "utf-8").split("\n");
954
+ let currentTitle = "";
955
+ let cwd = "";
956
+ let firstPrompt = "";
957
+ let lastActivity = "";
958
+ for (const line of lines) {
959
+ if (!line.trim()) continue;
960
+ try {
961
+ const entry = JSON.parse(line);
962
+ if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
963
+ if (!cwd && typeof entry.cwd === "string") cwd = entry.cwd;
964
+ if (!firstPrompt && entry.type === "user" && entry.message?.content) {
965
+ const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
966
+ if (text.startsWith("<")) continue;
967
+ firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
968
+ if (text.length > 120) firstPrompt += "…";
969
+ }
970
+ if (entry.message?.role === "assistant" && entry.message?.content) {
971
+ const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
972
+ type: "text",
973
+ text: entry.message.content
974
+ }];
975
+ for (const p of parts) if (p.type === "text" && p.text?.trim()) {
976
+ lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
977
+ if (p.text.length > 120) lastActivity += "…";
978
+ }
979
+ }
980
+ } catch {}
981
+ }
982
+ if (currentTitle !== name || !cwd) continue;
983
+ const stat = statSync(fullPath);
984
+ matches.push({
985
+ id: basename(f, ".jsonl"),
986
+ mtime: stat.mtimeMs,
987
+ size: stat.size,
988
+ firstPrompt,
989
+ lastActivity,
990
+ dir: cwd
991
+ });
992
+ } catch {}
993
+ }
994
+ }
995
+ return matches.sort((a, b) => b.mtime - a.mtime);
996
+ }
997
+ /**
998
+ * List all unique session names (customTitle) in a project directory.
999
+ * Used to show available names when a resume lookup fails.
1000
+ */
1001
+ function listSessionNames(dir) {
1002
+ const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
1003
+ if (!existsSync(projectDir)) return [];
1004
+ const results = [];
1005
+ const seen = /* @__PURE__ */ new Set();
1006
+ const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
1007
+ for (const f of files) {
1008
+ const fullPath = join(projectDir, f);
1009
+ try {
1010
+ const firstLine = readFileSync(fullPath, "utf-8").split("\n")[0];
1011
+ if (!firstLine) continue;
1012
+ const title = JSON.parse(firstLine).customTitle;
1013
+ if (!title || seen.has(title)) continue;
1014
+ seen.add(title);
1015
+ const stat = statSync(fullPath);
1016
+ results.push({
1017
+ name: title,
1018
+ id: basename(f, ".jsonl"),
1019
+ mtime: stat.mtimeMs
1020
+ });
1021
+ } catch {}
1022
+ }
1023
+ return results.sort((a, b) => b.mtime - a.mtime);
1024
+ }
1025
+ /**
1026
+ * Resolve a session ID prefix (e.g. "19aae7b4") to the full UUID by scanning
1027
+ * `~/.claude/projects/`. Returns the input unchanged if it already looks like
1028
+ * a full UUID, or null if no unique match exists. Pass `dir` to scope the
1029
+ * search to one project; otherwise every project is checked.
1030
+ *
1031
+ * `claude --resume <prefix>` does NOT accept truncated IDs — it treats them
1032
+ * as a search query and shows the picker. So callers must expand prefixes
1033
+ * before forwarding to claude.
1034
+ */
1035
+ function expandSessionId(input, dir) {
1036
+ if (!input) return null;
1037
+ 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;
1038
+ const projectsRoot = join(homedir(), ".claude", "projects");
1039
+ if (!existsSync(projectsRoot)) return null;
1040
+ const projectDirs = dir ? [join(projectsRoot, pathToProjectSlug(dir))] : readdirSync(projectsRoot).map((d) => join(projectsRoot, d)).filter((p) => {
1041
+ try {
1042
+ return statSync(p).isDirectory();
1043
+ } catch {
1044
+ return false;
1045
+ }
1046
+ });
1047
+ const matches = [];
1048
+ for (const pd of projectDirs) {
1049
+ if (!existsSync(pd)) continue;
1050
+ for (const f of readdirSync(pd)) {
1051
+ if (extname(f) !== ".jsonl") continue;
1052
+ const id = basename(f, ".jsonl");
1053
+ if (id.startsWith(input) && !matches.includes(id)) matches.push(id);
1054
+ }
1055
+ }
1056
+ return matches.length === 1 ? matches[0] : null;
1057
+ }
1058
+ //#endregion
822
1059
  //#region src/commands/sessions.ts
823
1060
  const sessionsCommand = define({
824
1061
  name: "sessions",
825
1062
  description: "List tabs with active/idle session status",
826
- args: {},
827
- async run() {
1063
+ args: { json: {
1064
+ type: "boolean",
1065
+ short: "j",
1066
+ description: "Emit machine-readable JSON. Output can be piped to `cctabs restore --manifest -` on another machine."
1067
+ } },
1068
+ async run(ctx) {
828
1069
  const adapter = requireAdapter();
829
1070
  const { tabsById, workspaces, tabNames } = await adapter.getAllData();
830
1071
  const currentTab = adapter.currentTabId();
831
1072
  const currentWs = adapter.currentWorkspaceId();
1073
+ if (ctx.values.json ?? false) {
1074
+ const out = { workspaces: [] };
1075
+ for (const wsp of workspaces) {
1076
+ const { oid, name: wsName, tabids } = wsp.workspacedata;
1077
+ const tabIds = tabids.filter((t) => tabsById.has(t));
1078
+ if (!tabIds.length) continue;
1079
+ const wsRow = {
1080
+ id: oid,
1081
+ name: wsName,
1082
+ current: oid === currentWs,
1083
+ sessions: []
1084
+ };
1085
+ for (const tabId of tabIds) {
1086
+ const termBlocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
1087
+ if (!termBlocks.length) continue;
1088
+ const tabName = tabNames.get(tabId) ?? tabId.slice(0, 8);
1089
+ const b = termBlocks[0];
1090
+ const cwd = b.meta?.["cmd:cwd"] ?? "";
1091
+ const status = adapter.detectSessionStatus(b.blockid);
1092
+ const lastLine = adapter.scrollback(b.blockid, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
1093
+ let sessionId = null;
1094
+ if (cwd) try {
1095
+ const matches = findSessionsByName(cwd, tabName);
1096
+ if (matches.length) sessionId = matches[0].id;
1097
+ } catch {}
1098
+ wsRow.sessions.push({
1099
+ block_id: b.blockid,
1100
+ tab_id: tabId,
1101
+ name: tabName,
1102
+ cwd,
1103
+ current: tabId === currentTab,
1104
+ status,
1105
+ last_line: lastLine.slice(0, 200),
1106
+ session_id: sessionId
1107
+ });
1108
+ }
1109
+ out.workspaces.push(wsRow);
1110
+ }
1111
+ adapter.closeSocket?.();
1112
+ console.log(JSON.stringify(out, null, 2));
1113
+ return;
1114
+ }
832
1115
  console.log("Sessions");
833
1116
  console.log("=".repeat(50));
834
1117
  for (const wsp of workspaces) {
@@ -1246,6 +1529,11 @@ const newCommand = define({
1246
1529
  short: "p",
1247
1530
  description: "Send initial prompt text once Claude is ready"
1248
1531
  },
1532
+ resume: {
1533
+ type: "string",
1534
+ short: "r",
1535
+ description: "Resume an existing Claude session ID (passes --resume <id> to claude). Mutually exclusive with --prompt/--file."
1536
+ },
1249
1537
  backend: {
1250
1538
  type: "string",
1251
1539
  short: "b",
@@ -1264,12 +1552,31 @@ const newCommand = define({
1264
1552
  const useWorktree = ctx.values.worktree ?? false;
1265
1553
  const promptFile = ctx.values.file;
1266
1554
  const promptText = ctx.values.prompt;
1555
+ const resumeId = ctx.values.resume;
1267
1556
  const backendName = ctx.values.backend;
1268
1557
  const modelOverride = ctx.values.model;
1269
1558
  if (!name) {
1270
1559
  consola.error("Tab name is required");
1271
1560
  process.exit(1);
1272
1561
  }
1562
+ if (resumeId && (promptText || promptFile)) {
1563
+ consola.error("--resume cannot be combined with --prompt or --file (you cannot send an initial prompt to a resumed session via this path).");
1564
+ process.exit(1);
1565
+ }
1566
+ let resolvedSessionId;
1567
+ if (resumeId) {
1568
+ const absDir = resolve(dir.replace(/^~/, homedir()));
1569
+ const expanded = expandSessionId(resumeId, absDir) ?? expandSessionId(resumeId);
1570
+ if (expanded) resolvedSessionId = expanded;
1571
+ else {
1572
+ const slug = pathToProjectSlug(absDir);
1573
+ if (existsSync(join(homedir(), ".claude", "projects", slug, `${resumeId}.jsonl`))) resolvedSessionId = resumeId;
1574
+ else {
1575
+ consola.warn(`Session ID "${resumeId}" not found in ~/.claude/projects/ — proceeding anyway (claude will error if invalid).`);
1576
+ resolvedSessionId = resumeId;
1577
+ }
1578
+ }
1579
+ }
1273
1580
  let envVars;
1274
1581
  let resolvedModel = modelOverride;
1275
1582
  if (backendName) {
@@ -1287,10 +1594,15 @@ const newCommand = define({
1287
1594
  initialPromptFile = join(tmpdir(), `cctabs-prompt-${Date.now()}.txt`);
1288
1595
  writeFileSync(initialPromptFile, promptText);
1289
1596
  } else if (promptFile) initialPromptFile = promptFile;
1597
+ let claudeCmd;
1598
+ if (resolvedSessionId) {
1599
+ const worktreePart = useWorktree ? ` --worktree ${JSON.stringify(name)}` : "";
1600
+ claudeCmd = `claude --resume ${resolvedSessionId}${worktreePart} --name ${JSON.stringify(name)}`;
1601
+ } else claudeCmd = useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude";
1290
1602
  const tabId = await openSession({
1291
1603
  tabName: name,
1292
1604
  dir,
1293
- claudeCmd: useWorktree ? `claude --worktree ${JSON.stringify(name)}` : "claude",
1605
+ claudeCmd,
1294
1606
  workspaceQuery: workspace,
1295
1607
  initialPromptFile,
1296
1608
  envVars,
@@ -1298,236 +1610,11 @@ const newCommand = define({
1298
1610
  });
1299
1611
  const wt = useWorktree ? ` (worktree: .claude/worktrees/${name})` : "";
1300
1612
  const be = backendName ? ` [backend: ${backendName}${resolvedModel ? ` → ${resolvedModel}` : ""}]` : "";
1301
- consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] claude at ${dir}${wt}${be}`);
1613
+ const rs = resolvedSessionId ? ` --resume ${resolvedSessionId.slice(0, 8)}…` : "";
1614
+ consola.success(`Tab "${name}" [${tabId.slice(0, 8)}] → claude${rs} at ${dir}${wt}${be}`);
1302
1615
  }
1303
1616
  });
1304
1617
  //#endregion
1305
- //#region src/core/session.ts
1306
- /** Convert an absolute path to Claude Code's project slug.
1307
- * Claude Code replaces any non-alphanumeric character (spaces, /, ., etc.) with '-'.
1308
- * Hyphens are preserved. Example: "/Users/me/Remember This" → "-Users-me-Remember-This". */
1309
- function pathToProjectSlug(dir) {
1310
- return resolve(dir).replace(/[^A-Za-z0-9-]/g, "-");
1311
- }
1312
- /** Find the most recent .jsonl session file in a Claude project directory */
1313
- function latestJsonlIn(projectDir) {
1314
- if (!existsSync(projectDir)) return null;
1315
- const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl").map((f) => ({
1316
- name: f,
1317
- mtime: statSync(join(projectDir, f)).mtimeMs
1318
- })).sort((a, b) => b.mtime - a.mtime);
1319
- return files.length ? basename(files[0].name, ".jsonl") : null;
1320
- }
1321
- /**
1322
- * Find the most recent Claude Code session ID for a directory.
1323
- * Also checks worktree subdirectories (.claude/worktrees/*) since tabs
1324
- * opened with --worktree run from a worktree path, not the repo root.
1325
- */
1326
- function findLatestSessionId(dir) {
1327
- const projectsRoot = join(homedir(), ".claude", "projects");
1328
- const direct = latestJsonlIn(join(projectsRoot, pathToProjectSlug(dir)));
1329
- if (direct) return direct;
1330
- const worktreesDir = join(dir, ".claude", "worktrees");
1331
- if (existsSync(worktreesDir)) {
1332
- const candidates = [];
1333
- for (const entry of readdirSync(worktreesDir)) {
1334
- const projectDir = join(projectsRoot, pathToProjectSlug(join(worktreesDir, entry)));
1335
- const id = latestJsonlIn(projectDir);
1336
- if (id) {
1337
- const mtime = statSync(join(projectDir, id + ".jsonl")).mtimeMs;
1338
- candidates.push({
1339
- id,
1340
- mtime
1341
- });
1342
- }
1343
- }
1344
- if (candidates.length) {
1345
- candidates.sort((a, b) => b.mtime - a.mtime);
1346
- return candidates[0].id;
1347
- }
1348
- }
1349
- return null;
1350
- }
1351
- /**
1352
- * Find all sessions with a given custom title (--name).
1353
- * Returns them sorted by most recent first, with the first user prompt for context.
1354
- */
1355
- function findSessionsByName(dir, name) {
1356
- const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
1357
- if (!existsSync(projectDir)) return [];
1358
- const matches = [];
1359
- const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
1360
- for (const f of files) {
1361
- const fullPath = join(projectDir, f);
1362
- try {
1363
- const lines = readFileSync(fullPath, "utf-8").split("\n");
1364
- let currentTitle = "";
1365
- let firstPrompt = "";
1366
- let lastActivity = "";
1367
- for (const line of lines) {
1368
- if (!line.trim()) continue;
1369
- try {
1370
- const entry = JSON.parse(line);
1371
- if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
1372
- if (!firstPrompt && entry.type === "user" && entry.message?.content) {
1373
- const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
1374
- if (text.startsWith("<")) continue;
1375
- firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
1376
- if (text.length > 120) firstPrompt += "…";
1377
- }
1378
- if (entry.message?.role === "assistant" && entry.message?.content) {
1379
- const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
1380
- type: "text",
1381
- text: entry.message.content
1382
- }];
1383
- for (const p of parts) if (p.type === "text" && p.text?.trim()) {
1384
- lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
1385
- if (p.text.length > 120) lastActivity += "…";
1386
- }
1387
- }
1388
- } catch {}
1389
- }
1390
- if (currentTitle !== name) continue;
1391
- const stat = statSync(fullPath);
1392
- matches.push({
1393
- id: basename(f, ".jsonl"),
1394
- mtime: stat.mtimeMs,
1395
- size: stat.size,
1396
- firstPrompt,
1397
- lastActivity
1398
- });
1399
- } catch {}
1400
- }
1401
- return matches.sort((a, b) => b.mtime - a.mtime);
1402
- }
1403
- /**
1404
- * Like findSessionsByName, but searches every project directory under
1405
- * ~/.claude/projects. Each match carries the cwd recorded in the session.
1406
- * Used by `cctabs restore` so callers don't have to guess the right dir.
1407
- */
1408
- function findSessionsByNameGlobally(name) {
1409
- const projectsRoot = join(homedir(), ".claude", "projects");
1410
- if (!existsSync(projectsRoot)) return [];
1411
- const matches = [];
1412
- for (const slug of readdirSync(projectsRoot)) {
1413
- const projectDir = join(projectsRoot, slug);
1414
- let isDir = false;
1415
- try {
1416
- isDir = statSync(projectDir).isDirectory();
1417
- } catch {
1418
- continue;
1419
- }
1420
- if (!isDir) continue;
1421
- const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
1422
- for (const f of files) {
1423
- const fullPath = join(projectDir, f);
1424
- try {
1425
- const lines = readFileSync(fullPath, "utf-8").split("\n");
1426
- let currentTitle = "";
1427
- let cwd = "";
1428
- let firstPrompt = "";
1429
- let lastActivity = "";
1430
- for (const line of lines) {
1431
- if (!line.trim()) continue;
1432
- try {
1433
- const entry = JSON.parse(line);
1434
- if (entry.customTitle !== void 0) currentTitle = entry.customTitle;
1435
- if (!cwd && typeof entry.cwd === "string") cwd = entry.cwd;
1436
- if (!firstPrompt && entry.type === "user" && entry.message?.content) {
1437
- const text = typeof entry.message.content === "string" ? entry.message.content : entry.message.content.find((c) => c.type === "text")?.text ?? "";
1438
- if (text.startsWith("<")) continue;
1439
- firstPrompt = text.slice(0, 120).replace(/\n/g, " ").trim();
1440
- if (text.length > 120) firstPrompt += "…";
1441
- }
1442
- if (entry.message?.role === "assistant" && entry.message?.content) {
1443
- const parts = Array.isArray(entry.message.content) ? entry.message.content : [{
1444
- type: "text",
1445
- text: entry.message.content
1446
- }];
1447
- for (const p of parts) if (p.type === "text" && p.text?.trim()) {
1448
- lastActivity = p.text.slice(0, 120).replace(/\n/g, " ").trim();
1449
- if (p.text.length > 120) lastActivity += "…";
1450
- }
1451
- }
1452
- } catch {}
1453
- }
1454
- if (currentTitle !== name || !cwd) continue;
1455
- const stat = statSync(fullPath);
1456
- matches.push({
1457
- id: basename(f, ".jsonl"),
1458
- mtime: stat.mtimeMs,
1459
- size: stat.size,
1460
- firstPrompt,
1461
- lastActivity,
1462
- dir: cwd
1463
- });
1464
- } catch {}
1465
- }
1466
- }
1467
- return matches.sort((a, b) => b.mtime - a.mtime);
1468
- }
1469
- /**
1470
- * List all unique session names (customTitle) in a project directory.
1471
- * Used to show available names when a resume lookup fails.
1472
- */
1473
- function listSessionNames(dir) {
1474
- const projectDir = join(join(homedir(), ".claude", "projects"), pathToProjectSlug(dir));
1475
- if (!existsSync(projectDir)) return [];
1476
- const results = [];
1477
- const seen = /* @__PURE__ */ new Set();
1478
- const files = readdirSync(projectDir).filter((f) => extname(f) === ".jsonl");
1479
- for (const f of files) {
1480
- const fullPath = join(projectDir, f);
1481
- try {
1482
- const firstLine = readFileSync(fullPath, "utf-8").split("\n")[0];
1483
- if (!firstLine) continue;
1484
- const title = JSON.parse(firstLine).customTitle;
1485
- if (!title || seen.has(title)) continue;
1486
- seen.add(title);
1487
- const stat = statSync(fullPath);
1488
- results.push({
1489
- name: title,
1490
- id: basename(f, ".jsonl"),
1491
- mtime: stat.mtimeMs
1492
- });
1493
- } catch {}
1494
- }
1495
- return results.sort((a, b) => b.mtime - a.mtime);
1496
- }
1497
- /**
1498
- * Resolve a session ID prefix (e.g. "19aae7b4") to the full UUID by scanning
1499
- * `~/.claude/projects/`. Returns the input unchanged if it already looks like
1500
- * a full UUID, or null if no unique match exists. Pass `dir` to scope the
1501
- * search to one project; otherwise every project is checked.
1502
- *
1503
- * `claude --resume <prefix>` does NOT accept truncated IDs — it treats them
1504
- * as a search query and shows the picker. So callers must expand prefixes
1505
- * before forwarding to claude.
1506
- */
1507
- function expandSessionId(input, dir) {
1508
- if (!input) return null;
1509
- 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;
1510
- const projectsRoot = join(homedir(), ".claude", "projects");
1511
- if (!existsSync(projectsRoot)) return null;
1512
- const projectDirs = dir ? [join(projectsRoot, pathToProjectSlug(dir))] : readdirSync(projectsRoot).map((d) => join(projectsRoot, d)).filter((p) => {
1513
- try {
1514
- return statSync(p).isDirectory();
1515
- } catch {
1516
- return false;
1517
- }
1518
- });
1519
- const matches = [];
1520
- for (const pd of projectDirs) {
1521
- if (!existsSync(pd)) continue;
1522
- for (const f of readdirSync(pd)) {
1523
- if (extname(f) !== ".jsonl") continue;
1524
- const id = basename(f, ".jsonl");
1525
- if (id.startsWith(input) && !matches.includes(id)) matches.push(id);
1526
- }
1527
- }
1528
- return matches.length === 1 ? matches[0] : null;
1529
- }
1530
- //#endregion
1531
1618
  //#region src/commands/resume.ts
1532
1619
  function shellQuoteEnv(env) {
1533
1620
  const entries = Object.entries(env);
@@ -1938,6 +2025,15 @@ const sendCommand = define({
1938
2025
  type: "boolean",
1939
2026
  short: "e",
1940
2027
  description: "Append newline after text (default: true)"
2028
+ },
2029
+ "wait-for-prompt": {
2030
+ type: "boolean",
2031
+ short: "w",
2032
+ description: "Poll the buffer until a shell prompt ($, %, >, ❯) is visible before sending. Useful for freshly-spawned tabs."
2033
+ },
2034
+ "wait-timeout": {
2035
+ type: "number",
2036
+ description: "Timeout in seconds for --wait-for-prompt (default: 10)"
1941
2037
  }
1942
2038
  },
1943
2039
  async run(ctx) {
@@ -1945,6 +2041,8 @@ const sendCommand = define({
1945
2041
  const inlineText = ctx.positionals[2];
1946
2042
  const filePath = ctx.values.file;
1947
2043
  const appendEnter = ctx.values.enter ?? true;
2044
+ const waitForPrompt = ctx.values["wait-for-prompt"] ?? false;
2045
+ const waitTimeoutSec = ctx.values["wait-timeout"] ?? 10;
1948
2046
  if (!query) {
1949
2047
  consola.error("Usage: cctabs send <tab-or-block> [text]");
1950
2048
  process.exit(1);
@@ -1983,6 +2081,23 @@ const sendCommand = define({
1983
2081
  }
1984
2082
  blockId = blockMatches[0].blockid;
1985
2083
  }
2084
+ if (waitForPrompt) {
2085
+ const deadline = Date.now() + waitTimeoutSec * 1e3;
2086
+ let ready = false;
2087
+ while (Date.now() < deadline) {
2088
+ const lastLine = adapter.scrollback(blockId, 5).split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
2089
+ if (/[$%>❯]\s*$/.test(lastLine)) {
2090
+ ready = true;
2091
+ break;
2092
+ }
2093
+ await new Promise((r) => setTimeout(r, 250));
2094
+ }
2095
+ if (!ready) {
2096
+ adapter.closeSocket();
2097
+ consola.error(`Timed out after ${waitTimeoutSec}s waiting for shell prompt in ${blockId.slice(0, 8)}`);
2098
+ process.exit(1);
2099
+ }
2100
+ }
1986
2101
  const resp = await adapter.sendInput(blockId, rawText);
1987
2102
  adapter.closeSocket();
1988
2103
  if (resp && resp.error) {
@@ -2010,154 +2125,378 @@ const configCommand = define({
2010
2125
  });
2011
2126
  //#endregion
2012
2127
  //#region src/commands/restore.ts
2128
+ function readStdinSync() {
2129
+ if (process.stdin.isTTY) return "";
2130
+ try {
2131
+ return readFileSync(0, "utf-8");
2132
+ } catch {
2133
+ return "";
2134
+ }
2135
+ }
2136
+ function parseManifest(raw) {
2137
+ let parsed;
2138
+ try {
2139
+ parsed = JSON.parse(raw);
2140
+ } catch (err) {
2141
+ throw new Error(`Manifest is not valid JSON: ${err.message}`);
2142
+ }
2143
+ const collected = [];
2144
+ if (Array.isArray(parsed)) collected.push(...parsed);
2145
+ else if (parsed && typeof parsed === "object") {
2146
+ const p = parsed;
2147
+ if (Array.isArray(p.sessions)) collected.push(...p.sessions);
2148
+ if (Array.isArray(p.workspaces)) {
2149
+ for (const ws of p.workspaces) if (ws && typeof ws === "object" && Array.isArray(ws.sessions)) collected.push(...ws.sessions);
2150
+ }
2151
+ }
2152
+ const entries = [];
2153
+ for (const item of collected) {
2154
+ if (!item || typeof item !== "object") continue;
2155
+ const it = item;
2156
+ const name = typeof it.name === "string" ? it.name : null;
2157
+ const dir = typeof it.dir === "string" ? it.dir : typeof it.cwd === "string" ? it.cwd : null;
2158
+ if (!name || !dir) continue;
2159
+ const sid = typeof it.session_id === "string" ? it.session_id : void 0;
2160
+ entries.push({
2161
+ name,
2162
+ dir: resolve(dir.replace(/^~/, homedir())),
2163
+ session_id: sid
2164
+ });
2165
+ }
2166
+ return entries;
2167
+ }
2013
2168
  const restoreCommand = define({
2014
2169
  name: "restore",
2015
- 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.",
2016
- args: { dry: {
2017
- type: "boolean",
2018
- short: "n",
2019
- description: "Show what would be resumed without actually doing it"
2020
- } },
2170
+ description: "Resume Claude sessions in terminal-state tabs (e.g. after a reboot). With --manifest, drive from an explicit list and optionally spawn missing tabs.",
2171
+ args: {
2172
+ dry: {
2173
+ type: "boolean",
2174
+ short: "n",
2175
+ description: "Show what would be resumed without actually doing it"
2176
+ },
2177
+ manifest: {
2178
+ type: "string",
2179
+ short: "m",
2180
+ description: "Path to a JSON manifest of {name, dir, session_id?} entries (use \"-\" for stdin). Accepts cctabs sessions --json output directly."
2181
+ },
2182
+ "create-missing": {
2183
+ type: "boolean",
2184
+ short: "c",
2185
+ description: "When using --manifest, spawn new tabs for entries that have no existing tab"
2186
+ }
2187
+ },
2021
2188
  async run(ctx) {
2022
- const rawDir = ctx.positionals[1];
2023
- const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
2024
2189
  const dryRun = ctx.values.dry;
2025
- const adapter = requireAdapter();
2026
- const { tabsById, workspaces, tabNames } = await adapter.getAllData();
2027
- const currentTab = adapter.currentTabId();
2028
- const tabs = [];
2029
- for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
2030
- if (tabId === currentTab) continue;
2031
- const blocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
2032
- if (!blocks.length) continue;
2033
- const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
2034
- const status = adapter.detectSessionStatus(blocks[0].blockid);
2035
- tabs.push({
2036
- tabId,
2037
- name,
2038
- blockId: blocks[0].blockid,
2039
- status
2190
+ const manifestPath = ctx.values.manifest;
2191
+ const createMissing = ctx.values["create-missing"] ?? false;
2192
+ if (manifestPath) {
2193
+ await runManifestMode(manifestPath, createMissing, !!dryRun);
2194
+ return;
2195
+ }
2196
+ if (createMissing) consola.warn("--create-missing has no effect without --manifest; ignoring.");
2197
+ await runLegacyMode(ctx.positionals[1], !!dryRun);
2198
+ }
2199
+ });
2200
+ async function runManifestMode(manifestPath, createMissing, dryRun) {
2201
+ let raw;
2202
+ if (manifestPath === "-") {
2203
+ raw = readStdinSync();
2204
+ if (!raw.trim()) {
2205
+ consola.error("--manifest - was given but stdin is empty");
2206
+ process.exit(1);
2207
+ }
2208
+ } else {
2209
+ if (!existsSync(manifestPath)) {
2210
+ consola.error(`Manifest file not found: ${manifestPath}`);
2211
+ process.exit(1);
2212
+ }
2213
+ raw = readFileSync(manifestPath, "utf-8");
2214
+ }
2215
+ let entries;
2216
+ try {
2217
+ entries = parseManifest(raw);
2218
+ } catch (err) {
2219
+ consola.error(err.message);
2220
+ process.exit(1);
2221
+ }
2222
+ if (!entries.length) {
2223
+ consola.error("Manifest contained no usable entries (need at minimum {name, dir} per entry).");
2224
+ process.exit(1);
2225
+ }
2226
+ consola.info(`Manifest: ${entries.length} entry/entries`);
2227
+ const adapter = requireAdapter();
2228
+ const { tabsById, tabNames, workspaces } = await adapter.getAllData();
2229
+ const currentWs = adapter.currentWorkspaceId();
2230
+ const currentTab = adapter.currentTabId();
2231
+ const currentWsData = workspaces.find((w) => w.workspacedata.oid === currentWs);
2232
+ const wsTabIds = currentWsData ? new Set(currentWsData.workspacedata.tabids) : new Set(tabsById.keys());
2233
+ const results = [];
2234
+ const toSpawn = [];
2235
+ const extraFlags = loadConfig().claude.flags.join(" ");
2236
+ for (const entry of entries) {
2237
+ let resolvedSessionId = entry.session_id;
2238
+ if (entry.session_id) {
2239
+ const expanded = expandSessionId(entry.session_id, entry.dir) ?? expandSessionId(entry.session_id);
2240
+ if (expanded) resolvedSessionId = expanded;
2241
+ } else {
2242
+ const sessions = findSessionsByName(entry.dir, entry.name);
2243
+ if (sessions.length === 1) resolvedSessionId = sessions[0].id;
2244
+ else if (sessions.length > 1) resolvedSessionId = sessions[0].id;
2245
+ }
2246
+ const matchingTabs = adapter.resolveTab(entry.name, tabsById, tabNames).filter((tid) => wsTabIds.has(tid) && tid !== currentTab);
2247
+ if (matchingTabs.length > 1) {
2248
+ consola.log(` ${entry.name} — multiple matching tabs, skipping`);
2249
+ results.push({
2250
+ name: entry.name,
2251
+ result: "ambiguous (multiple tabs)"
2040
2252
  });
2253
+ continue;
2041
2254
  }
2042
- const toResume = tabs.filter((t) => t.status === "terminal" || t.status === "unknown");
2043
- const alreadyActive = tabs.filter((t) => t.status === "active" || t.status === "idle");
2044
- if (alreadyActive.length) consola.info(`Already running: ${alreadyActive.map((t) => t.name).join(", ")}`);
2045
- if (!toResume.length) {
2046
- consola.info("No terminal-state tabs to restore.");
2047
- adapter.closeSocket();
2048
- return;
2255
+ if (matchingTabs.length === 1) {
2256
+ const tabId = matchingTabs[0];
2257
+ const termBlock = (tabsById.get(tabId) ?? []).find((b) => b.view === "term");
2258
+ if (!termBlock) {
2259
+ results.push({
2260
+ name: entry.name,
2261
+ result: "no terminal block in tab"
2262
+ });
2263
+ continue;
2264
+ }
2265
+ const status = adapter.detectSessionStatus(termBlock.blockid);
2266
+ if (status === "active" || status === "idle") {
2267
+ consola.log(` ${entry.name} — already running, skipping`);
2268
+ results.push({
2269
+ name: entry.name,
2270
+ result: "already running"
2271
+ });
2272
+ continue;
2273
+ }
2274
+ if (!resolvedSessionId) {
2275
+ consola.log(` ${entry.name} — no session ID and none found in ${entry.dir}, skipping`);
2276
+ results.push({
2277
+ name: entry.name,
2278
+ result: "no matching session"
2279
+ });
2280
+ continue;
2281
+ }
2282
+ if (dryRun) {
2283
+ consola.log(` ${entry.name} → would resume ${resolvedSessionId.slice(0, 8)}… in existing tab`);
2284
+ results.push({
2285
+ name: entry.name,
2286
+ result: `dry run: attach ${resolvedSessionId.slice(0, 8)}…`
2287
+ });
2288
+ continue;
2289
+ }
2290
+ consola.log(` ${entry.name} → resuming ${resolvedSessionId.slice(0, 8)}… in existing tab`);
2291
+ const cmd = `cd ${JSON.stringify(entry.dir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${resolvedSessionId} --name ${JSON.stringify(entry.name)}\r`;
2292
+ await adapter.sendInput(termBlock.blockid, cmd);
2293
+ await new Promise((r) => setTimeout(r, 500));
2294
+ results.push({
2295
+ name: entry.name,
2296
+ result: "sent"
2297
+ });
2298
+ continue;
2049
2299
  }
2050
- consola.info(`Found ${toResume.length} tab(s) to restore:`);
2051
- const extraFlags = loadConfig().claude.flags.join(" ");
2052
- const results = [];
2053
- const toRecreate = [];
2054
- for (const tab of toResume) {
2055
- let sessionId = null;
2056
- let sessionDir = null;
2057
- if (scopedDir) {
2058
- const sessions = findSessionsByName(scopedDir, tab.name);
2059
- if (sessions.length === 0) {
2060
- consola.log(` ${tab.name} no session named "${tab.name}" found in ${scopedDir}, skipping`);
2061
- results.push({
2062
- name: tab.name,
2063
- result: "no matching session"
2064
- });
2065
- continue;
2066
- }
2067
- if (sessions.length > 1) {
2068
- consola.log(` ${tab.name} — multiple sessions found, skipping (use cctabs resume --session to pick one)`);
2069
- results.push({
2070
- name: tab.name,
2071
- result: "ambiguous (multiple sessions)"
2072
- });
2073
- continue;
2074
- }
2075
- sessionId = sessions[0].id;
2076
- sessionDir = scopedDir;
2077
- } else {
2078
- const sessions = findSessionsByNameGlobally(tab.name);
2079
- if (sessions.length === 0) {
2080
- consola.log(` ${tab.name} — no session named "${tab.name}" found in any project, skipping`);
2081
- results.push({
2082
- name: tab.name,
2083
- result: "no matching session"
2084
- });
2300
+ if (!createMissing) {
2301
+ consola.log(` ${entry.name} no existing tab; pass --create-missing to spawn one`);
2302
+ results.push({
2303
+ name: entry.name,
2304
+ result: "missing (skipped, no --create-missing)"
2305
+ });
2306
+ continue;
2307
+ }
2308
+ if (dryRun) {
2309
+ const sid = resolvedSessionId ? `${resolvedSessionId.slice(0, 8)}…` : "fresh";
2310
+ consola.log(` ${entry.name} would spawn new tab in ${entry.dir} (${sid})`);
2311
+ results.push({
2312
+ name: entry.name,
2313
+ result: `dry run: spawn (${sid})`
2314
+ });
2315
+ continue;
2316
+ }
2317
+ toSpawn.push({
2318
+ ...entry,
2319
+ session_id: resolvedSessionId
2320
+ });
2321
+ }
2322
+ if (!dryRun) {
2323
+ const sent = results.filter((r) => r.result === "sent");
2324
+ if (sent.length) {
2325
+ consola.info("Waiting for sessions to start…");
2326
+ await new Promise((r) => setTimeout(r, 1e4));
2327
+ for (const r of sent) {
2328
+ const tabId = adapter.resolveTab(r.name, tabsById, tabNames).filter((tid) => wsTabIds.has(tid) && tid !== currentTab)[0];
2329
+ const termBlock = tabId ? (tabsById.get(tabId) ?? []).find((b) => b.view === "term") : void 0;
2330
+ if (!termBlock) {
2331
+ r.result = "? tab disappeared";
2085
2332
  continue;
2086
2333
  }
2087
- if (sessions.length > 1) consola.log(` ${tab.name} — multiple sessions found across projects, picking newest (${sessions[0].dir})`);
2088
- sessionId = sessions[0].id;
2089
- sessionDir = sessions[0].dir;
2334
+ const status = adapter.detectSessionStatus(termBlock.blockid);
2335
+ if (status === "active" || status === "idle") r.result = "✔ running";
2336
+ else if (status === "unknown") r.result = "? scrollback unavailable";
2337
+ else r.result = "✘ may not have started";
2090
2338
  }
2091
- if (dryRun) {
2092
- const mode = tab.status === "unknown" ? "recreate" : "send";
2093
- consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}… in ${sessionDir}`);
2339
+ }
2340
+ }
2341
+ adapter.closeSocket();
2342
+ for (const entry of toSpawn) try {
2343
+ const claudeCmd = entry.session_id ? `claude --resume ${entry.session_id} --name ${JSON.stringify(entry.name)}` : "claude";
2344
+ const newTabId = await openSession({
2345
+ tabName: entry.name,
2346
+ dir: entry.dir,
2347
+ claudeCmd
2348
+ });
2349
+ const sid = entry.session_id ? entry.session_id.slice(0, 8) + "…" : "fresh";
2350
+ results.push({
2351
+ name: entry.name,
2352
+ result: `✔ spawned [${newTabId.slice(0, 8)}] (${sid})`
2353
+ });
2354
+ } catch (err) {
2355
+ results.push({
2356
+ name: entry.name,
2357
+ result: `✘ spawn failed: ${err.message}`
2358
+ });
2359
+ }
2360
+ console.log("\nRestore summary:");
2361
+ for (const r of results) console.log(` ${r.name}: ${r.result}`);
2362
+ }
2363
+ async function runLegacyMode(rawDir, dryRun) {
2364
+ const scopedDir = rawDir ? resolve(rawDir.replace(/^~/, homedir())) : null;
2365
+ const adapter = requireAdapter();
2366
+ const { tabsById, workspaces, tabNames } = await adapter.getAllData();
2367
+ const currentTab = adapter.currentTabId();
2368
+ const tabs = [];
2369
+ for (const wsp of workspaces) for (const tabId of wsp.workspacedata.tabids) {
2370
+ if (tabId === currentTab) continue;
2371
+ const blocks = (tabsById.get(tabId) ?? []).filter((b) => b.view === "term");
2372
+ if (!blocks.length) continue;
2373
+ const name = tabNames.get(tabId) ?? tabId.slice(0, 8);
2374
+ const status = adapter.detectSessionStatus(blocks[0].blockid);
2375
+ tabs.push({
2376
+ tabId,
2377
+ name,
2378
+ blockId: blocks[0].blockid,
2379
+ status
2380
+ });
2381
+ }
2382
+ const toResume = tabs.filter((t) => t.status === "terminal" || t.status === "unknown");
2383
+ const alreadyActive = tabs.filter((t) => t.status === "active" || t.status === "idle");
2384
+ if (alreadyActive.length) consola.info(`Already running: ${alreadyActive.map((t) => t.name).join(", ")}`);
2385
+ if (!toResume.length) {
2386
+ consola.info("No terminal-state tabs to restore.");
2387
+ adapter.closeSocket();
2388
+ return;
2389
+ }
2390
+ consola.info(`Found ${toResume.length} tab(s) to restore:`);
2391
+ const extraFlags = loadConfig().claude.flags.join(" ");
2392
+ const results = [];
2393
+ const toRecreate = [];
2394
+ for (const tab of toResume) {
2395
+ let sessionId = null;
2396
+ let sessionDir = null;
2397
+ if (scopedDir) {
2398
+ const sessions = findSessionsByName(scopedDir, tab.name);
2399
+ if (sessions.length === 0) {
2400
+ consola.log(` ${tab.name} — no session named "${tab.name}" found in ${scopedDir}, skipping`);
2094
2401
  results.push({
2095
2402
  name: tab.name,
2096
- result: `dry run: ${sessionId.slice(0, 8)}…`
2403
+ result: "no matching session"
2097
2404
  });
2098
2405
  continue;
2099
2406
  }
2100
- if (tab.status === "unknown") {
2101
- if (await adapter.confirmScrollbackEmpty(tab.blockId)) {
2102
- const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2103
- toRecreate.push({
2104
- name: tab.name,
2105
- sessionId,
2106
- sessionDir,
2107
- blockIds,
2108
- tabId: tab.tabId
2109
- });
2110
- results.push({
2111
- name: tab.name,
2112
- result: "queued for recreate"
2113
- });
2114
- continue;
2115
- }
2407
+ if (sessions.length > 1) {
2408
+ consola.log(` ${tab.name} — multiple sessions found, skipping (use cctabs resume --session to pick one)`);
2409
+ results.push({
2410
+ name: tab.name,
2411
+ result: "ambiguous (multiple sessions)"
2412
+ });
2413
+ continue;
2116
2414
  }
2117
- consola.log(` ${tab.name} resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
2118
- const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
2119
- await adapter.sendInput(tab.blockId, cmd);
2120
- await new Promise((r) => setTimeout(r, 500));
2415
+ sessionId = sessions[0].id;
2416
+ sessionDir = scopedDir;
2417
+ } else {
2418
+ const sessions = findSessionsByNameGlobally(tab.name);
2419
+ if (sessions.length === 0) {
2420
+ consola.log(` ${tab.name} — no session named "${tab.name}" found in any project, skipping`);
2421
+ results.push({
2422
+ name: tab.name,
2423
+ result: "no matching session"
2424
+ });
2425
+ continue;
2426
+ }
2427
+ if (sessions.length > 1) consola.log(` ${tab.name} — multiple sessions found across projects, picking newest (${sessions[0].dir})`);
2428
+ sessionId = sessions[0].id;
2429
+ sessionDir = sessions[0].dir;
2430
+ }
2431
+ if (dryRun) {
2432
+ const mode = tab.status === "unknown" ? "recreate" : "send";
2433
+ consola.log(` ${tab.name} → would ${mode} session ${sessionId.slice(0, 8)}… in ${sessionDir}`);
2121
2434
  results.push({
2122
2435
  name: tab.name,
2123
- result: "sent"
2436
+ result: `dry run: ${sessionId.slice(0, 8)}…`
2124
2437
  });
2438
+ continue;
2125
2439
  }
2126
- if (!dryRun) {
2127
- const sent = results.filter((r) => r.result === "sent");
2128
- if (sent.length) {
2129
- consola.info("Waiting for sessions to start…");
2130
- await new Promise((r) => setTimeout(r, 1e4));
2131
- for (const r of sent) {
2132
- const tab = toResume.find((t) => t.name === r.name);
2133
- const status = adapter.detectSessionStatus(tab.blockId);
2134
- if (status === "active" || status === "idle") r.result = "✔ running";
2135
- else if (status === "unknown") r.result = "? scrollback unavailable";
2136
- else r.result = "✘ may not have started";
2137
- }
2440
+ if (tab.status === "unknown") {
2441
+ if (await adapter.confirmScrollbackEmpty(tab.blockId)) {
2442
+ const blockIds = (tabsById.get(tab.tabId) ?? []).map((b) => b.blockid);
2443
+ toRecreate.push({
2444
+ name: tab.name,
2445
+ sessionId,
2446
+ sessionDir,
2447
+ blockIds,
2448
+ tabId: tab.tabId
2449
+ });
2450
+ results.push({
2451
+ name: tab.name,
2452
+ result: "queued for recreate"
2453
+ });
2454
+ continue;
2138
2455
  }
2139
2456
  }
2140
- if (!dryRun && toRecreate.length) {
2141
- for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
2142
- adapter.closeSocket();
2143
- consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
2144
- for (const t of toRecreate) try {
2145
- const newTabId = await openSession({
2146
- tabName: t.name,
2147
- dir: t.sessionDir,
2148
- claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
2149
- });
2150
- const r = results.find((x) => x.name === t.name);
2151
- r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
2152
- } catch (err) {
2153
- const r = results.find((x) => x.name === t.name);
2154
- r.result = `✘ recreate failed: ${err.message}`;
2457
+ consola.log(` ${tab.name} resuming session ${sessionId.slice(0, 8)}… in ${sessionDir} (send)`);
2458
+ const cmd = `cd ${JSON.stringify(sessionDir)} && claude${extraFlags ? " " + extraFlags : ""} --resume ${sessionId} --name ${JSON.stringify(tab.name)}\r`;
2459
+ await adapter.sendInput(tab.blockId, cmd);
2460
+ await new Promise((r) => setTimeout(r, 500));
2461
+ results.push({
2462
+ name: tab.name,
2463
+ result: "sent"
2464
+ });
2465
+ }
2466
+ if (!dryRun) {
2467
+ const sent = results.filter((r) => r.result === "sent");
2468
+ if (sent.length) {
2469
+ consola.info("Waiting for sessions to start…");
2470
+ await new Promise((r) => setTimeout(r, 1e4));
2471
+ for (const r of sent) {
2472
+ const tab = toResume.find((t) => t.name === r.name);
2473
+ const status = adapter.detectSessionStatus(tab.blockId);
2474
+ if (status === "active" || status === "idle") r.result = "✔ running";
2475
+ else if (status === "unknown") r.result = "? scrollback unavailable";
2476
+ else r.result = "✘ may not have started";
2155
2477
  }
2156
- } else adapter.closeSocket();
2157
- console.log("\nRestore summary:");
2158
- for (const r of results) console.log(` ${r.name}: ${r.result}`);
2478
+ }
2159
2479
  }
2160
- });
2480
+ if (!dryRun && toRecreate.length) {
2481
+ for (const t of toRecreate) for (const bid of t.blockIds) adapter.deleteBlock(bid);
2482
+ adapter.closeSocket();
2483
+ consola.info(`Recreating ${toRecreate.length} dead tab(s)…`);
2484
+ for (const t of toRecreate) try {
2485
+ const newTabId = await openSession({
2486
+ tabName: t.name,
2487
+ dir: t.sessionDir,
2488
+ claudeCmd: `claude --resume ${t.sessionId} --name ${JSON.stringify(t.name)}`
2489
+ });
2490
+ const r = results.find((x) => x.name === t.name);
2491
+ r.result = `✔ recreated [${newTabId.slice(0, 8)}]`;
2492
+ } catch (err) {
2493
+ const r = results.find((x) => x.name === t.name);
2494
+ r.result = `✘ recreate failed: ${err.message}`;
2495
+ }
2496
+ } else adapter.closeSocket();
2497
+ console.log("\nRestore summary:");
2498
+ for (const r of results) console.log(` ${r.name}: ${r.result}`);
2499
+ }
2161
2500
  //#endregion
2162
2501
  //#region src/commands/backends.ts
2163
2502
  const backendsCommand = define({
@@ -2538,7 +2877,8 @@ async function run() {
2538
2877
  name,
2539
2878
  version,
2540
2879
  description,
2541
- subCommands
2880
+ subCommands,
2881
+ renderHeader: null
2542
2882
  });
2543
2883
  }
2544
2884
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@generativereality/cctabs",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Claude Code tab manager. Terminal tabs as the UI, no tmux.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,13 @@
1
1
  ---
2
2
  name: cctabs
3
- description: Manage Claude Code sessions across terminal tabs (NOT browser tabs) — list running sessions, open new ones, fork, close, inspect output, and send input. Use this when working with multiple parallel Claude Code sessions in terminal tabs.
3
+ description: |
4
+ Manage Claude Code sessions across terminal tabs (Wave Terminal or Tabby) — list, open, fork, close, inspect output, send input. Each terminal tab runs its own Claude Code session.
5
+
6
+ TRIGGER when the user says any of: "open a new tab", "open a new cctab" (singular alias), "spawn a tab", "a new cctabs session", "in another tab", "in a separate tab", "fork this tab", "list my tabs", "close that tab", "send to <tab>", "resume <name>" — anything that refers to a terminal tab running Claude Code. ALSO trigger for: "/cctabs", or when the user mentions Wave Terminal / Tabby tab management for Claude Code.
7
+
8
+ DO NOT confuse with the Agent tool (background subagents): if the user explicitly says "tab" / "cctab" / "cctabs" they want a separate Claude Code session in a real terminal tab — call this skill, not Agent. The Agent tool is correct when the user says "subagent", "background agent", "spawn an agent", "do this in parallel without a new tab", or when the work is interconnected with the current session's filesystem state.
9
+
10
+ NOT for: browser tabs (use playwright/browser-automation), tmux panes, screen sessions, or non-Claude terminals.
4
11
  ---
5
12
 
6
13
  You are managing Claude Code sessions using the `cctabs` CLI.