@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -2
- package/dist/index.js +701 -361
- package/package.json +1 -1
- package/skills/cctabs/SKILL.md +8 -1
|
@@ -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.
|
|
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
|
|
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]
|
|
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.
|
|
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,
|
|
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
|
-
"
|
|
652
|
-
"
|
|
653
|
-
"
|
|
654
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2016
|
-
args: {
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
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
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
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
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
const
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
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
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
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:
|
|
2403
|
+
result: "no matching session"
|
|
2097
2404
|
});
|
|
2098
2405
|
continue;
|
|
2099
2406
|
}
|
|
2100
|
-
if (
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
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:
|
|
2436
|
+
result: `dry run: ${sessionId.slice(0, 8)}…`
|
|
2124
2437
|
});
|
|
2438
|
+
continue;
|
|
2125
2439
|
}
|
|
2126
|
-
if (
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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
|
-
}
|
|
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
package/skills/cctabs/SKILL.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cctabs
|
|
3
|
-
description:
|
|
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.
|