copilot-sessions 1.3.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haim Cohen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: copilot-sessions
3
+ Version: 1.3.0
4
+ Summary: Terminal tool to view and manage GitHub Copilot CLI sessions
5
+ Author: Haim Cohen
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sk3pp3r/copilot-sessions
8
+ Project-URL: Issues, https://github.com/sk3pp3r/copilot-sessions/issues
9
+ Keywords: copilot,github,cli,sessions
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Environment :: Console
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: rich>=13.0.0
19
+ Dynamic: license-file
20
+
21
+ # 🤖 Copilot Sessions
22
+
23
+ A terminal tool to view and manage your GitHub Copilot CLI sessions — track usage, costs, premium requests, kill stale sessions, and clean up old ones.
24
+
25
+ ![Python](https://img.shields.io/badge/python-3.9+-blue)
26
+ ![License](https://img.shields.io/badge/license-MIT-green)
27
+
28
+ ## Features
29
+
30
+ - **List & filter** all Copilot CLI sessions
31
+ - **View details** with full 💰 usage breakdown (tokens, models, premium requests, cost)
32
+ - **Active sessions** — see what's running live
33
+ - **Kill** stale/orphan live sessions
34
+ - **Delete & cleanup** old sessions by age
35
+ - **Statistics** — total usage across all sessions
36
+ - **Resume command** — copy-paste to jump back into any session
37
+
38
+ ## Installation
39
+
40
+ ### pipx (Recommended)
41
+ ```bash
42
+ pipx install copilot-sessions
43
+ ```
44
+
45
+ ### pip
46
+ ```bash
47
+ pip install copilot-sessions
48
+ ```
49
+
50
+ ### From source
51
+ ```bash
52
+ git clone https://github.com/sk3pp3r/copilot-sessions.git
53
+ cd copilot-sessions
54
+ pipx install .
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ copilot-sessions # launch interactive manager
61
+ copilot-sessions --active # show active sessions only
62
+ copilot-sessions --version # show version
63
+ copilot-sessions --help # full help
64
+ ```
65
+
66
+ ### Interactive Commands
67
+
68
+ | Key | Action |
69
+ |-----|--------|
70
+ | `l` | List all sessions |
71
+ | `f` | Filter/search sessions |
72
+ | `a` | Active sessions |
73
+ | `v` | View session detail + 💰 Usage |
74
+ | `s` | Show statistics |
75
+ | `d` | Delete session(s) |
76
+ | `k` | Kill live session(s) |
77
+ | `c` | Cleanup old sessions |
78
+ | `r` | Refresh |
79
+ | `q` | Quit |
80
+
81
+ ## Requirements
82
+
83
+ - Python 3.9+
84
+ - [rich](https://github.com/Textualize/rich) (installed automatically)
85
+ - GitHub Copilot CLI (`~/.copilot/session-state/` must exist)
86
+
87
+ ## License
88
+
89
+ MIT © Haim Cohen
@@ -0,0 +1,69 @@
1
+ # 🤖 Copilot Sessions
2
+
3
+ A terminal tool to view and manage your GitHub Copilot CLI sessions — track usage, costs, premium requests, kill stale sessions, and clean up old ones.
4
+
5
+ ![Python](https://img.shields.io/badge/python-3.9+-blue)
6
+ ![License](https://img.shields.io/badge/license-MIT-green)
7
+
8
+ ## Features
9
+
10
+ - **List & filter** all Copilot CLI sessions
11
+ - **View details** with full 💰 usage breakdown (tokens, models, premium requests, cost)
12
+ - **Active sessions** — see what's running live
13
+ - **Kill** stale/orphan live sessions
14
+ - **Delete & cleanup** old sessions by age
15
+ - **Statistics** — total usage across all sessions
16
+ - **Resume command** — copy-paste to jump back into any session
17
+
18
+ ## Installation
19
+
20
+ ### pipx (Recommended)
21
+ ```bash
22
+ pipx install copilot-sessions
23
+ ```
24
+
25
+ ### pip
26
+ ```bash
27
+ pip install copilot-sessions
28
+ ```
29
+
30
+ ### From source
31
+ ```bash
32
+ git clone https://github.com/sk3pp3r/copilot-sessions.git
33
+ cd copilot-sessions
34
+ pipx install .
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ copilot-sessions # launch interactive manager
41
+ copilot-sessions --active # show active sessions only
42
+ copilot-sessions --version # show version
43
+ copilot-sessions --help # full help
44
+ ```
45
+
46
+ ### Interactive Commands
47
+
48
+ | Key | Action |
49
+ |-----|--------|
50
+ | `l` | List all sessions |
51
+ | `f` | Filter/search sessions |
52
+ | `a` | Active sessions |
53
+ | `v` | View session detail + 💰 Usage |
54
+ | `s` | Show statistics |
55
+ | `d` | Delete session(s) |
56
+ | `k` | Kill live session(s) |
57
+ | `c` | Cleanup old sessions |
58
+ | `r` | Refresh |
59
+ | `q` | Quit |
60
+
61
+ ## Requirements
62
+
63
+ - Python 3.9+
64
+ - [rich](https://github.com/Textualize/rich) (installed automatically)
65
+ - GitHub Copilot CLI (`~/.copilot/session-state/` must exist)
66
+
67
+ ## License
68
+
69
+ MIT © Haim Cohen
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "copilot-sessions"
7
+ version = "1.3.0"
8
+ description = "Terminal tool to view and manage GitHub Copilot CLI sessions"
9
+ authors = [
10
+ { name = "Haim Cohen" },
11
+ ]
12
+ license = { text = "MIT" }
13
+ dependencies = [
14
+ "rich>=13.0.0",
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Environment :: Console",
21
+ "Topic :: Utilities",
22
+ ]
23
+ readme = "README.md"
24
+ requires-python = ">=3.9"
25
+ keywords = ["copilot", "github", "cli", "sessions"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/sk3pp3r/copilot-sessions"
29
+ Issues = "https://github.com/sk3pp3r/copilot-sessions/issues"
30
+
31
+ [project.scripts]
32
+ copilot-sessions = "copilot_dashboard.main:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,920 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Copilot CLI Sessions Manager
4
+ A terminal-based tool to view and manage GitHub Copilot CLI sessions.
5
+ """
6
+
7
+ import argparse
8
+ import csv
9
+ import json
10
+ import os
11
+ import shutil
12
+ import sys
13
+ import time
14
+ from datetime import datetime, timezone
15
+ from io import StringIO
16
+ from pathlib import Path
17
+
18
+ __app_name__ = "Copilot Sessions"
19
+ __version__ = "1.3.0"
20
+ __author__ = "Haim Cohen"
21
+ __description__ = "Terminal tool to view and manage GitHub Copilot CLI sessions"
22
+
23
+ try:
24
+ from rich.console import Console
25
+ from rich.table import Table
26
+ from rich.panel import Panel
27
+ from rich.prompt import Prompt, Confirm, IntPrompt
28
+ from rich.text import Text
29
+ from rich.columns import Columns
30
+ from rich import box
31
+ except ImportError:
32
+ print("Error: 'rich' library is required. Install with: pip3 install rich")
33
+ sys.exit(1)
34
+
35
+ SESSION_STATE_DIR = Path.home() / ".copilot" / "session-state"
36
+ console = Console()
37
+
38
+
39
+ def parse_yaml_simple(text: str) -> dict:
40
+ """Minimal YAML parser for workspace.yaml (flat key-value only)."""
41
+ result = {}
42
+ for line in text.strip().splitlines():
43
+ if ":" in line:
44
+ key, _, value = line.partition(":")
45
+ result[key.strip()] = value.strip()
46
+ return result
47
+
48
+
49
+ def get_session_info(session_dir: Path) -> dict:
50
+ """Extract all metadata for a session."""
51
+ sid = session_dir.name
52
+ info = {
53
+ "id": sid,
54
+ "short_id": sid[:8],
55
+ "summary": "",
56
+ "cwd": "",
57
+ "git_root": "",
58
+ "repository": "",
59
+ "branch": "",
60
+ "created_at": "",
61
+ "updated_at": "",
62
+ "copilot_version": "",
63
+ "active": False,
64
+ "pid": None,
65
+ "has_plan": False,
66
+ "has_db": False,
67
+ "user_messages": 0,
68
+ "events_size": 0,
69
+ "first_user_msg": "",
70
+ "dir": session_dir,
71
+ # Metrics (accumulated across shutdown events)
72
+ "total_premium_reqs": 0,
73
+ "total_api_duration_ms": 0,
74
+ "current_model": "",
75
+ "current_tokens": 0,
76
+ "system_tokens": 0,
77
+ "conversation_tokens": 0,
78
+ "tool_def_tokens": 0,
79
+ "model_metrics": {}, # {model: {requests, cost, input_tokens, output_tokens, ...}}
80
+ "lines_added": 0,
81
+ "lines_removed": 0,
82
+ "files_modified": set(),
83
+ # Live session fallback (from individual events)
84
+ "live_output_tokens": 0,
85
+ "live_models": set(),
86
+ }
87
+
88
+ # workspace.yaml
89
+ ws_file = session_dir / "workspace.yaml"
90
+ if ws_file.exists():
91
+ try:
92
+ ws = parse_yaml_simple(ws_file.read_text())
93
+ info["summary"] = ws.get("summary", "")
94
+ info["cwd"] = ws.get("cwd", "")
95
+ info["git_root"] = ws.get("git_root", "")
96
+ info["repository"] = ws.get("repository", "")
97
+ info["branch"] = ws.get("branch", "")
98
+ info["created_at"] = ws.get("created_at", "")
99
+ info["updated_at"] = ws.get("updated_at", "")
100
+ except Exception:
101
+ pass
102
+
103
+ # Lock file = active session
104
+ for f in session_dir.glob("inuse.*.lock"):
105
+ info["active"] = True
106
+ try:
107
+ info["pid"] = int(f.stem.split(".")[-1])
108
+ except (ValueError, IndexError):
109
+ pass
110
+ break
111
+
112
+ # plan.md
113
+ info["has_plan"] = (session_dir / "plan.md").exists()
114
+ info["has_db"] = (session_dir / "session.db").exists()
115
+
116
+ # events.jsonl
117
+ events_file = session_dir / "events.jsonl"
118
+ if events_file.exists():
119
+ info["events_size"] = events_file.stat().st_size
120
+ try:
121
+ with open(events_file) as f:
122
+ for line in f:
123
+ try:
124
+ evt = json.loads(line)
125
+ except json.JSONDecodeError:
126
+ continue
127
+ if evt.get("type") == "session.start":
128
+ data = evt.get("data", {})
129
+ info["copilot_version"] = data.get("copilotVersion", "")
130
+ if not info["created_at"]:
131
+ info["created_at"] = data.get("startTime", "")
132
+ ctx = data.get("context", {})
133
+ if not info["cwd"]:
134
+ info["cwd"] = ctx.get("cwd", "")
135
+ if not info["repository"]:
136
+ info["repository"] = ctx.get("repository", "")
137
+ if not info["branch"]:
138
+ info["branch"] = ctx.get("branch", "")
139
+ elif evt.get("type") == "user.message":
140
+ info["user_messages"] += 1
141
+ if not info["first_user_msg"]:
142
+ content = evt.get("data", {}).get("content", "")
143
+ info["first_user_msg"] = content[:100].replace("\n", " ")
144
+ elif evt.get("type") == "session.shutdown":
145
+ sd = evt.get("data", {})
146
+ info["total_premium_reqs"] += sd.get("totalPremiumRequests", 0)
147
+ info["total_api_duration_ms"] += sd.get("totalApiDurationMs", 0)
148
+ info["current_model"] = sd.get("currentModel", info["current_model"])
149
+ info["current_tokens"] = sd.get("currentTokens", 0)
150
+ info["system_tokens"] = sd.get("systemTokens", 0)
151
+ info["conversation_tokens"] = sd.get("conversationTokens", 0)
152
+ info["tool_def_tokens"] = sd.get("toolDefinitionsTokens", 0)
153
+ # Accumulate model metrics across resume segments
154
+ for model, metrics in sd.get("modelMetrics", {}).items():
155
+ if model not in info["model_metrics"]:
156
+ info["model_metrics"][model] = {
157
+ "requests": 0, "cost": 0,
158
+ "input_tokens": 0, "output_tokens": 0,
159
+ "cache_read_tokens": 0, "cache_write_tokens": 0,
160
+ }
161
+ mm = info["model_metrics"][model]
162
+ reqs = metrics.get("requests", {})
163
+ mm["requests"] += reqs.get("count", 0)
164
+ mm["cost"] += reqs.get("cost", 0)
165
+ usage = metrics.get("usage", {})
166
+ mm["input_tokens"] += usage.get("inputTokens", 0)
167
+ mm["output_tokens"] += usage.get("outputTokens", 0)
168
+ mm["cache_read_tokens"] += usage.get("cacheReadTokens", 0)
169
+ mm["cache_write_tokens"] += usage.get("cacheWriteTokens", 0)
170
+ # Accumulate code changes
171
+ cc = sd.get("codeChanges", {})
172
+ info["lines_added"] += cc.get("linesAdded", 0)
173
+ info["lines_removed"] += cc.get("linesRemoved", 0)
174
+ for fp in cc.get("filesModified", []):
175
+ info["files_modified"].add(fp)
176
+ elif evt.get("type") == "assistant.message":
177
+ ot = evt.get("data", {}).get("outputTokens", 0)
178
+ if ot:
179
+ info["live_output_tokens"] += ot
180
+ elif evt.get("type") == "tool.execution_complete":
181
+ m = evt.get("data", {}).get("model", "")
182
+ if m:
183
+ info["live_models"].add(m)
184
+ except Exception:
185
+ pass
186
+
187
+ # Fallback: use dir mtime for updated_at
188
+ if not info["updated_at"] and events_file.exists():
189
+ mtime = events_file.stat().st_mtime
190
+ info["updated_at"] = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
191
+ if not info["created_at"]:
192
+ ctime = session_dir.stat().st_birthtime if hasattr(session_dir.stat(), "st_birthtime") else session_dir.stat().st_ctime
193
+ info["created_at"] = datetime.fromtimestamp(ctime, tz=timezone.utc).isoformat()
194
+
195
+ return info
196
+
197
+
198
+ def format_size(size_bytes: int) -> str:
199
+ if size_bytes < 1024:
200
+ return f"{size_bytes} B"
201
+ elif size_bytes < 1024 * 1024:
202
+ return f"{size_bytes / 1024:.1f} KB"
203
+ else:
204
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
205
+
206
+
207
+ def format_date(iso_str: str) -> str:
208
+ if not iso_str:
209
+ return "—"
210
+ try:
211
+ dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
212
+ now = datetime.now(timezone.utc)
213
+ delta = now - dt
214
+ if delta.days == 0:
215
+ hours = delta.seconds // 3600
216
+ if hours == 0:
217
+ mins = delta.seconds // 60
218
+ return f"{mins}m ago"
219
+ return f"{hours}h ago"
220
+ elif delta.days < 7:
221
+ return f"{delta.days}d ago"
222
+ else:
223
+ return dt.strftime("%Y-%m-%d")
224
+ except Exception:
225
+ return iso_str[:10]
226
+
227
+
228
+ def load_all_sessions() -> list[dict]:
229
+ sessions = []
230
+ if not SESSION_STATE_DIR.exists():
231
+ return sessions
232
+ for entry in SESSION_STATE_DIR.iterdir():
233
+ if entry.is_dir() and len(entry.name) == 36 and "-" in entry.name:
234
+ sessions.append(get_session_info(entry))
235
+ # Sort by updated_at descending (most recent first)
236
+ sessions.sort(key=lambda s: s.get("updated_at", ""), reverse=True)
237
+ return sessions
238
+
239
+
240
+ def show_session_list(sessions: list[dict], filter_text: str = ""):
241
+ filtered = sessions
242
+ if filter_text:
243
+ fl = filter_text.lower()
244
+ filtered = [
245
+ s for s in sessions
246
+ if fl in s["summary"].lower()
247
+ or fl in s["id"].lower()
248
+ or fl in s["cwd"].lower()
249
+ or fl in s["repository"].lower()
250
+ or fl in s["first_user_msg"].lower()
251
+ ]
252
+
253
+ table = Table(
254
+ title=f"🤖 {__app_name__} v{__version__}",
255
+ box=box.ROUNDED,
256
+ show_lines=True,
257
+ title_style="bold cyan",
258
+ caption=f"{len(filtered)} sessions (of {len(sessions)} total) · © {__author__}",
259
+ caption_style="dim",
260
+ )
261
+
262
+ table.add_column("#", style="dim", width=4, justify="right")
263
+ table.add_column("Status", width=8, justify="center")
264
+ table.add_column("Summary", style="bold", min_width=20, max_width=35)
265
+ table.add_column("ID", style="dim cyan", width=10)
266
+ table.add_column("Working Dir", style="green", max_width=25)
267
+ table.add_column("Repo / Branch", style="yellow", max_width=25)
268
+ table.add_column("Created", style="blue", width=12)
269
+ table.add_column("Updated", style="blue", width=12)
270
+ table.add_column("Msgs", justify="right", width=5)
271
+ table.add_column("Size", justify="right", width=8)
272
+ table.add_column("Extras", width=8)
273
+
274
+ for i, s in enumerate(filtered):
275
+ status = Text("● LIVE", style="bold green") if s["active"] else Text("○ idle", style="dim")
276
+ summary = s["summary"] or s["first_user_msg"][:35] or "—"
277
+ cwd = s["cwd"].replace(str(Path.home()), "~") if s["cwd"] else "—"
278
+ repo_branch = ""
279
+ if s["repository"]:
280
+ repo_branch = s["repository"]
281
+ if s["branch"]:
282
+ repo_branch += f" ({s['branch']})" if repo_branch else s["branch"]
283
+ repo_branch = repo_branch or "—"
284
+
285
+ extras = []
286
+ if s["has_plan"]:
287
+ extras.append("📋")
288
+ if s["has_db"]:
289
+ extras.append("🗃️")
290
+
291
+ table.add_row(
292
+ str(i + 1),
293
+ status,
294
+ summary,
295
+ s["short_id"] + "…",
296
+ cwd,
297
+ repo_branch,
298
+ format_date(s["created_at"]),
299
+ format_date(s["updated_at"]),
300
+ str(s["user_messages"]),
301
+ format_size(s["events_size"]),
302
+ " ".join(extras),
303
+ )
304
+
305
+ console.print()
306
+ console.print(table)
307
+ return filtered
308
+
309
+
310
+ def show_session_detail(session: dict):
311
+ console.print()
312
+ panel_content = []
313
+ panel_content.append(f"[bold cyan]Session ID:[/] {session['id']}")
314
+ panel_content.append(f"[bold cyan]Summary:[/] {session['summary'] or '—'}")
315
+ panel_content.append(f"[bold cyan]Status:[/] {'[bold green]● ACTIVE[/]' if session['active'] else '[dim]○ Idle[/]'}")
316
+ panel_content.append(f"[bold cyan]Working Dir:[/] {session['cwd'] or '—'}")
317
+ panel_content.append(f"[bold cyan]Git Root:[/] {session['git_root'] or '—'}")
318
+ panel_content.append(f"[bold cyan]Repository:[/] {session['repository'] or '—'}")
319
+ panel_content.append(f"[bold cyan]Branch:[/] {session['branch'] or '—'}")
320
+ panel_content.append(f"[bold cyan]Copilot Version:[/] {session['copilot_version'] or '—'}")
321
+ panel_content.append(f"[bold cyan]Created:[/] {session['created_at'] or '—'}")
322
+ panel_content.append(f"[bold cyan]Updated:[/] {session['updated_at'] or '—'}")
323
+ panel_content.append(f"[bold cyan]User Messages:[/] {session['user_messages']}")
324
+ panel_content.append(f"[bold cyan]Events Size:[/] {format_size(session['events_size'])}")
325
+ panel_content.append(f"[bold cyan]Has Plan:[/] {'✅ Yes' if session['has_plan'] else '❌ No'}")
326
+ panel_content.append(f"[bold cyan]Has DB:[/] {'✅ Yes' if session['has_db'] else '❌ No'}")
327
+ panel_content.append(f"[bold cyan]Directory:[/] {session['dir']}")
328
+ if session["first_user_msg"]:
329
+ panel_content.append(f"\n[bold cyan]First Message:[/]\n [italic]{session['first_user_msg']}[/]")
330
+
331
+ cwd = session["cwd"] or "~"
332
+ resume_cmd = f"cd {cwd} && copilot --resume={session['id']}"
333
+ panel_content.append(f"\n[bold green]▶ Resume:[/]\n [white on grey23] {resume_cmd} [/]")
334
+
335
+ console.print(Panel(
336
+ "\n".join(panel_content),
337
+ title=f"📋 Session Detail — {session['short_id']}…",
338
+ border_style="cyan",
339
+ expand=True,
340
+ ))
341
+
342
+ # Usage & Cost panel
343
+ mm = session["model_metrics"]
344
+ if mm:
345
+ usage_table = Table(
346
+ box=box.SIMPLE_HEAVY,
347
+ show_header=True,
348
+ title_style="bold magenta",
349
+ expand=True,
350
+ )
351
+ usage_table.add_column("Model", style="bold yellow")
352
+ usage_table.add_column("Requests", justify="right", style="cyan")
353
+ usage_table.add_column("Premium Reqs", justify="right", style="bold red")
354
+ usage_table.add_column("Input Tokens", justify="right")
355
+ usage_table.add_column("Output Tokens", justify="right")
356
+ usage_table.add_column("Cache Read", justify="right", style="dim")
357
+ usage_table.add_column("Cache Write", justify="right", style="dim")
358
+
359
+ total_reqs = 0
360
+ total_cost = 0
361
+ total_input = 0
362
+ total_output = 0
363
+ total_cache_r = 0
364
+ total_cache_w = 0
365
+
366
+ for model, m in sorted(mm.items()):
367
+ total_reqs += m["requests"]
368
+ total_cost += m["cost"]
369
+ total_input += m["input_tokens"]
370
+ total_output += m["output_tokens"]
371
+ total_cache_r += m["cache_read_tokens"]
372
+ total_cache_w += m["cache_write_tokens"]
373
+ usage_table.add_row(
374
+ model,
375
+ f"{m['requests']:,}",
376
+ f"{m['cost']:,}",
377
+ f"{m['input_tokens']:,}",
378
+ f"{m['output_tokens']:,}",
379
+ f"{m['cache_read_tokens']:,}",
380
+ f"{m['cache_write_tokens']:,}",
381
+ )
382
+
383
+ if len(mm) > 1:
384
+ usage_table.add_row(
385
+ "[bold]TOTAL[/]",
386
+ f"[bold]{total_reqs:,}[/]",
387
+ f"[bold]{total_cost:,}[/]",
388
+ f"[bold]{total_input:,}[/]",
389
+ f"[bold]{total_output:,}[/]",
390
+ f"[bold]{total_cache_r:,}[/]",
391
+ f"[bold]{total_cache_w:,}[/]",
392
+ )
393
+
394
+ # Summary line
395
+ api_secs = session["total_api_duration_ms"] / 1000
396
+ api_time = f"{api_secs:.0f}s" if api_secs < 60 else f"{api_secs / 60:.1f}m"
397
+
398
+ summary_parts = []
399
+ summary_parts.append(f"[bold]Premium Requests:[/] [bold red]{session['total_premium_reqs']}[/]")
400
+ summary_parts.append(f"[bold]API Time:[/] {api_time}")
401
+ summary_parts.append(f"[bold]Current Model:[/] [yellow]{session['current_model']}[/]")
402
+ if session["conversation_tokens"]:
403
+ summary_parts.append(
404
+ f"[bold]Context Window:[/] {session['current_tokens']:,} tokens "
405
+ f"(conversation: {session['conversation_tokens']:,}, "
406
+ f"system: {session['system_tokens']:,}, "
407
+ f"tools: {session['tool_def_tokens']:,})"
408
+ )
409
+
410
+ code_parts = []
411
+ if session["lines_added"] or session["lines_removed"]:
412
+ code_parts.append(
413
+ f"[bold]Code Changes:[/] "
414
+ f"[green]+{session['lines_added']}[/] / [red]-{session['lines_removed']}[/] lines"
415
+ f" in {len(session['files_modified'])} file(s)"
416
+ )
417
+
418
+ console.print(Panel(
419
+ "\n".join(summary_parts)
420
+ + ("\n" + "\n".join(code_parts) if code_parts else ""),
421
+ title="💰 Usage & Cost",
422
+ border_style="magenta",
423
+ expand=True,
424
+ ))
425
+ console.print(usage_table)
426
+
427
+ elif session["active"] and (session["live_output_tokens"] or session["live_models"]):
428
+ # Live session without shutdown data yet
429
+ live_parts = []
430
+ live_parts.append(f"[bold yellow]⚡ Live session — full metrics available after session ends[/]")
431
+ if session["live_models"]:
432
+ live_parts.append(f"[bold]Models in use:[/] {', '.join(sorted(session['live_models']))}")
433
+ if session["live_output_tokens"]:
434
+ live_parts.append(f"[bold]Output tokens so far:[/] {session['live_output_tokens']:,}")
435
+ console.print(Panel("\n".join(live_parts), title="💰 Usage (partial)", border_style="yellow", expand=True))
436
+
437
+ # Show plan preview if exists
438
+ plan_file = session["dir"] / "plan.md"
439
+ if plan_file.exists():
440
+ plan_text = plan_file.read_text()[:500]
441
+ console.print(Panel(plan_text, title="📝 Plan Preview", border_style="yellow", expand=True))
442
+
443
+
444
+ def delete_sessions(sessions: list[dict], indices: list[int]):
445
+ to_delete = []
446
+ for idx in indices:
447
+ s = sessions[idx]
448
+ if s["active"]:
449
+ console.print(f" [bold red]⚠ Skipping {s['short_id']}… — session is ACTIVE (use 'k' to kill first)[/]")
450
+ else:
451
+ to_delete.append(s)
452
+
453
+ if not to_delete:
454
+ console.print("[yellow]No sessions to delete.[/]")
455
+ return
456
+
457
+ console.print(f"\n[bold red]About to delete {len(to_delete)} session(s):[/]")
458
+ for s in to_delete:
459
+ summary = s["summary"] or s["first_user_msg"][:40] or "no summary"
460
+ console.print(f" • {s['short_id']}… — {summary}")
461
+
462
+ if Confirm.ask("\n[bold]Confirm deletion?[/]", default=False):
463
+ for s in to_delete:
464
+ try:
465
+ shutil.rmtree(s["dir"])
466
+ # Also remove standalone .jsonl if exists
467
+ jsonl = SESSION_STATE_DIR / f"{s['id']}.jsonl"
468
+ if jsonl.exists():
469
+ jsonl.unlink()
470
+ console.print(f" [green]✓[/] Deleted {s['short_id']}…")
471
+ except Exception as e:
472
+ console.print(f" [red]✗[/] Failed to delete {s['short_id']}…: {e}")
473
+ else:
474
+ console.print("[dim]Cancelled.[/]")
475
+
476
+
477
+ def kill_sessions(sessions: list[dict]):
478
+ """Kill active (live) sessions by terminating their process and removing lock files."""
479
+ import signal
480
+
481
+ active = [s for s in sessions if s["active"]]
482
+ if not active:
483
+ console.print("[yellow]No active sessions to kill.[/]")
484
+ return
485
+
486
+ table = Table(box=box.SIMPLE, show_header=True, title="⚡ Active Sessions", title_style="bold red")
487
+ table.add_column("#", width=4, justify="right", style="dim")
488
+ table.add_column("Summary", style="bold")
489
+ table.add_column("ID", style="dim cyan", width=10)
490
+ table.add_column("PID", style="red", justify="right")
491
+ table.add_column("Working Dir", style="green")
492
+
493
+ for i, s in enumerate(active):
494
+ summary = s["summary"] or s["first_user_msg"][:35] or "—"
495
+ cwd = s["cwd"].replace(str(Path.home()), "~") if s["cwd"] else "—"
496
+ table.add_row(str(i + 1), summary, s["short_id"] + "…", str(s["pid"] or "?"), cwd)
497
+
498
+ console.print(table)
499
+
500
+ raw = Prompt.ask(
501
+ f"[bold red]Session #(s) to kill[/] (comma-separated, or 'all')",
502
+ )
503
+
504
+ if raw.strip().lower() == "all":
505
+ targets = list(range(len(active)))
506
+ else:
507
+ try:
508
+ targets = [int(x.strip()) - 1 for x in raw.split(",")]
509
+ if not all(0 <= i < len(active) for i in targets):
510
+ console.print("[red]Invalid number(s).[/]")
511
+ return
512
+ except ValueError:
513
+ console.print("[red]Invalid input.[/]")
514
+ return
515
+
516
+ to_kill = [active[i] for i in targets]
517
+ console.print(f"\n[bold red]About to kill {len(to_kill)} session(s):[/]")
518
+ for s in to_kill:
519
+ summary = s["summary"] or s["first_user_msg"][:40] or "no summary"
520
+ console.print(f" 💀 {s['short_id']}… — {summary} (PID {s['pid'] or '?'})")
521
+
522
+ if not Confirm.ask("\n[bold]Confirm kill?[/]", default=False):
523
+ console.print("[dim]Cancelled.[/]")
524
+ return
525
+
526
+ for s in to_kill:
527
+ pid = s["pid"]
528
+ sid_short = s["short_id"]
529
+
530
+ # Kill the process
531
+ if pid:
532
+ try:
533
+ os.kill(pid, signal.SIGTERM)
534
+ console.print(f" [green]✓[/] Sent SIGTERM to PID {pid} ({sid_short}…)")
535
+ except ProcessLookupError:
536
+ console.print(f" [yellow]⚠[/] PID {pid} not found — process already dead ({sid_short}…)")
537
+ except PermissionError:
538
+ console.print(f" [red]✗[/] Permission denied killing PID {pid} ({sid_short}…)")
539
+ continue
540
+ else:
541
+ console.print(f" [yellow]⚠[/] No PID found for {sid_short}… — removing lock file only")
542
+
543
+ # Remove lock files
544
+ for lock in s["dir"].glob("inuse.*.lock"):
545
+ try:
546
+ lock.unlink()
547
+ console.print(f" [green]✓[/] Removed lock {lock.name}")
548
+ except Exception as e:
549
+ console.print(f" [red]✗[/] Failed to remove lock: {e}")
550
+
551
+
552
+ def show_stats(sessions: list[dict]):
553
+ total = len(sessions)
554
+ active = sum(1 for s in sessions if s["active"])
555
+ with_plan = sum(1 for s in sessions if s["has_plan"])
556
+ with_db = sum(1 for s in sessions if s["has_db"])
557
+ total_size = sum(s["events_size"] for s in sessions)
558
+ total_msgs = sum(s["user_messages"] for s in sessions)
559
+ total_premium = sum(s["total_premium_reqs"] for s in sessions)
560
+ total_disk = sum(
561
+ sum(f.stat().st_size for f in s["dir"].rglob("*") if f.is_file())
562
+ for s in sessions
563
+ )
564
+
565
+ repos = set(s["repository"] for s in sessions if s["repository"])
566
+ versions = set(s["copilot_version"] for s in sessions if s["copilot_version"])
567
+
568
+ stats = Table(title="📊 Session Statistics", box=box.SIMPLE_HEAVY, show_header=False, title_style="bold magenta")
569
+ stats.add_column("Metric", style="bold")
570
+ stats.add_column("Value", style="cyan")
571
+
572
+ stats.add_row("Total Sessions", str(total))
573
+ stats.add_row("Active (live)", str(active))
574
+ stats.add_row("Inactive", str(total - active))
575
+ stats.add_row("With Plan", str(with_plan))
576
+ stats.add_row("With Database", str(with_db))
577
+ stats.add_row("Total User Messages", str(total_msgs))
578
+ stats.add_row("Total Premium Requests", str(total_premium))
579
+ stats.add_row("Events Data", format_size(total_size))
580
+ stats.add_row("Total Disk Usage", format_size(total_disk))
581
+ stats.add_row("Repositories", ", ".join(sorted(repos)) or "—")
582
+ stats.add_row("Copilot Versions", ", ".join(sorted(versions)) or "—")
583
+
584
+ console.print()
585
+ console.print(stats)
586
+
587
+
588
+ def cleanup_old_sessions(sessions: list[dict]):
589
+ console.print("\n[bold]Cleanup — delete inactive sessions older than N days[/]")
590
+ days = IntPrompt.ask("Delete sessions older than how many days?", default=30)
591
+
592
+ cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)
593
+ candidates = []
594
+ for s in sessions:
595
+ if s["active"]:
596
+ continue
597
+ try:
598
+ dt = datetime.fromisoformat(s["updated_at"].replace("Z", "+00:00"))
599
+ if dt.timestamp() < cutoff:
600
+ candidates.append(s)
601
+ except Exception:
602
+ pass
603
+
604
+ if not candidates:
605
+ console.print(f"[green]No inactive sessions older than {days} days found.[/]")
606
+ return
607
+
608
+ console.print(f"\n[yellow]Found {len(candidates)} session(s) older than {days} days:[/]")
609
+ for s in candidates:
610
+ summary = s["summary"] or s["first_user_msg"][:40] or "no summary"
611
+ console.print(f" • {s['short_id']}… — {summary} (updated {format_date(s['updated_at'])})")
612
+
613
+ if Confirm.ask(f"\n[bold red]Delete all {len(candidates)} sessions?[/]", default=False):
614
+ for s in candidates:
615
+ try:
616
+ shutil.rmtree(s["dir"])
617
+ jsonl = SESSION_STATE_DIR / f"{s['id']}.jsonl"
618
+ if jsonl.exists():
619
+ jsonl.unlink()
620
+ console.print(f" [green]✓[/] Deleted {s['short_id']}…")
621
+ except Exception as e:
622
+ console.print(f" [red]✗[/] Failed: {e}")
623
+ else:
624
+ console.print("[dim]Cancelled.[/]")
625
+
626
+
627
+ def export_sessions(sessions: list[dict]):
628
+ """Export sessions to CSV, JSON, or HTML."""
629
+ fmt = Prompt.ask(
630
+ "[bold]Export format[/]",
631
+ choices=["csv", "json", "html"],
632
+ default="csv",
633
+ )
634
+
635
+ default_name = f"copilot-sessions.{fmt}"
636
+ filepath = Prompt.ask("[bold]File path[/]", default=default_name)
637
+
638
+ # Build export data
639
+ rows = []
640
+ for s in sessions:
641
+ total_input = sum(m["input_tokens"] for m in s["model_metrics"].values())
642
+ total_output = sum(m["output_tokens"] for m in s["model_metrics"].values())
643
+ total_reqs = sum(m["requests"] for m in s["model_metrics"].values())
644
+ models_used = ", ".join(sorted(s["model_metrics"].keys())) or ", ".join(sorted(s["live_models"])) or "—"
645
+
646
+ rows.append({
647
+ "id": s["id"],
648
+ "summary": s["summary"] or s["first_user_msg"][:60] or "—",
649
+ "status": "ACTIVE" if s["active"] else "idle",
650
+ "cwd": s["cwd"],
651
+ "repository": s["repository"],
652
+ "branch": s["branch"],
653
+ "copilot_version": s["copilot_version"],
654
+ "created_at": s["created_at"],
655
+ "updated_at": s["updated_at"],
656
+ "user_messages": s["user_messages"],
657
+ "events_size_bytes": s["events_size"],
658
+ "premium_requests": s["total_premium_reqs"],
659
+ "total_requests": total_reqs,
660
+ "input_tokens": total_input,
661
+ "output_tokens": total_output,
662
+ "models": models_used,
663
+ "api_duration_sec": round(s["total_api_duration_ms"] / 1000, 1),
664
+ "lines_added": s["lines_added"],
665
+ "lines_removed": s["lines_removed"],
666
+ "files_modified": len(s["files_modified"]),
667
+ "has_plan": s["has_plan"],
668
+ "has_db": s["has_db"],
669
+ "resume_cmd": f"cd {s['cwd'] or '~'} && copilot --resume={s['id']}",
670
+ })
671
+
672
+ try:
673
+ if fmt == "csv":
674
+ with open(filepath, "w", newline="") as f:
675
+ writer = csv.DictWriter(f, fieldnames=rows[0].keys())
676
+ writer.writeheader()
677
+ writer.writerows(rows)
678
+
679
+ elif fmt == "json":
680
+ with open(filepath, "w") as f:
681
+ json.dump(rows, f, indent=2, default=str)
682
+
683
+ elif fmt == "html":
684
+ with open(filepath, "w") as f:
685
+ f.write(_generate_html(rows))
686
+
687
+ console.print(f" [green]✓[/] Exported {len(rows)} sessions to [bold]{filepath}[/]")
688
+ except Exception as e:
689
+ console.print(f" [red]✗[/] Export failed: {e}")
690
+
691
+
692
+ def _generate_html(rows: list[dict]) -> str:
693
+ """Generate a styled HTML report."""
694
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
695
+ html = f"""<!DOCTYPE html>
696
+ <html lang="en">
697
+ <head>
698
+ <meta charset="UTF-8">
699
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
700
+ <title>🤖 {__app_name__}</title>
701
+ <style>
702
+ :root {{ --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9;
703
+ --cyan: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d29922;
704
+ --magenta: #bc8cff; --dim: #8b949e; }}
705
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
706
+ body {{ background: var(--bg); color: var(--text); font-family: 'SF Mono', 'Cascadia Code', monospace;
707
+ padding: 24px; line-height: 1.6; }}
708
+ h1 {{ color: var(--cyan); margin-bottom: 4px; font-size: 1.4em; }}
709
+ .subtitle {{ color: var(--dim); margin-bottom: 20px; font-size: 0.85em; }}
710
+ .stats {{ display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }}
711
+ .stat {{ background: var(--card); border: 1px solid var(--border); border-radius: 8px;
712
+ padding: 12px 20px; min-width: 140px; }}
713
+ .stat .label {{ color: var(--dim); font-size: 0.75em; text-transform: uppercase; }}
714
+ .stat .value {{ color: var(--cyan); font-size: 1.3em; font-weight: bold; }}
715
+ .stat .value.green {{ color: var(--green); }}
716
+ .stat .value.red {{ color: var(--red); }}
717
+ .stat .value.magenta {{ color: var(--magenta); }}
718
+ table {{ width: 100%; border-collapse: collapse; background: var(--card);
719
+ border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-top: 12px; }}
720
+ th {{ background: #21262d; color: var(--cyan); padding: 10px 12px; text-align: left;
721
+ font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid var(--border); }}
722
+ td {{ padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85em;
723
+ vertical-align: top; }}
724
+ tr:hover {{ background: #1c2128; }}
725
+ .active {{ color: var(--green); font-weight: bold; }}
726
+ .idle {{ color: var(--dim); }}
727
+ .num {{ text-align: right; font-variant-numeric: tabular-nums; }}
728
+ .resume {{ font-size: 0.75em; color: var(--dim); word-break: break-all; }}
729
+ footer {{ margin-top: 24px; color: var(--dim); font-size: 0.8em; text-align: center; }}
730
+ </style>
731
+ </head>
732
+ <body>
733
+ <h1>🤖 {__app_name__}</h1>
734
+ <div class="subtitle">Generated {now} · v{__version__} · © {__author__} 2026</div>
735
+ """
736
+ total = len(rows)
737
+ active = sum(1 for r in rows if r["status"] == "ACTIVE")
738
+ total_premium = sum(r["premium_requests"] for r in rows)
739
+ total_msgs = sum(r["user_messages"] for r in rows)
740
+
741
+ html += f"""<div class="stats">
742
+ <div class="stat"><div class="label">Sessions</div><div class="value">{total}</div></div>
743
+ <div class="stat"><div class="label">Active</div><div class="value green">{active}</div></div>
744
+ <div class="stat"><div class="label">Premium Reqs</div><div class="value magenta">{total_premium:,}</div></div>
745
+ <div class="stat"><div class="label">Messages</div><div class="value">{total_msgs:,}</div></div>
746
+ </div>
747
+ """
748
+ html += """<table>
749
+ <tr>
750
+ <th>Status</th><th>Summary</th><th>ID</th><th>Working Dir</th>
751
+ <th>Repo / Branch</th><th>Created</th><th>Updated</th>
752
+ <th>Msgs</th><th>Premium</th><th>Input Tok</th><th>Output Tok</th>
753
+ <th>Models</th><th>Resume</th>
754
+ </tr>
755
+ """
756
+ for r in rows:
757
+ status_cls = "active" if r["status"] == "ACTIVE" else "idle"
758
+ status_icon = "● LIVE" if r["status"] == "ACTIVE" else "○ idle"
759
+ repo_branch = r["repository"]
760
+ if r["branch"]:
761
+ repo_branch += f" ({r['branch']})" if repo_branch else r["branch"]
762
+
763
+ html += f"""<tr>
764
+ <td class="{status_cls}">{status_icon}</td>
765
+ <td>{r['summary']}</td>
766
+ <td style="color:var(--cyan);font-size:0.8em">{r['id'][:12]}…</td>
767
+ <td style="font-size:0.8em">{r['cwd']}</td>
768
+ <td style="color:var(--yellow)">{repo_branch or '—'}</td>
769
+ <td>{r['created_at'][:10]}</td>
770
+ <td>{r['updated_at'][:10]}</td>
771
+ <td class="num">{r['user_messages']}</td>
772
+ <td class="num" style="color:var(--magenta)">{r['premium_requests']}</td>
773
+ <td class="num">{r['input_tokens']:,}</td>
774
+ <td class="num">{r['output_tokens']:,}</td>
775
+ <td style="font-size:0.8em">{r['models']}</td>
776
+ <td class="resume">{r['resume_cmd']}</td>
777
+ </tr>
778
+ """
779
+
780
+ html += f"""</table>
781
+ <footer>{__app_name__} v{__version__} · © {__author__} 2026</footer>
782
+ </body>
783
+ </html>"""
784
+ return html
785
+
786
+
787
+ def main():
788
+ parser = argparse.ArgumentParser(
789
+ prog="copilot-sessions",
790
+ description=__description__,
791
+ formatter_class=argparse.RawDescriptionHelpFormatter,
792
+ epilog=(
793
+ "Interactive commands:\n"
794
+ " l List all sessions f Filter/search sessions\n"
795
+ " a Active sessions v View session detail + Usage\n"
796
+ " s Show statistics d Delete session(s)\n"
797
+ " k Kill live session(s) c Cleanup old sessions\n"
798
+ " e Export (CSV/JSON/HTML) r Refresh\n"
799
+ " q Quit\n"
800
+ "\n"
801
+ "Resume a session:\n"
802
+ " cd <working-dir> && copilot --resume=<session-id>\n"
803
+ "\n"
804
+ "Examples:\n"
805
+ " copilot-sessions # launch interactive manager\n"
806
+ " copilot-sessions --active # show active sessions only\n"
807
+ " copilot-sessions --version # show version\n"
808
+ ),
809
+ )
810
+ parser.add_argument(
811
+ "-v", "--version",
812
+ action="version",
813
+ version=f"%(prog)s {__version__} by {__author__}",
814
+ )
815
+ parser.add_argument(
816
+ "-a", "--active",
817
+ action="store_true",
818
+ help="Show only active (live) sessions and exit",
819
+ )
820
+ args = parser.parse_args()
821
+
822
+ if args.active:
823
+ sessions = load_all_sessions()
824
+ active = [s for s in sessions if s["active"]]
825
+ if not active:
826
+ console.print("[yellow]No active sessions found.[/]")
827
+ sys.exit(0)
828
+ show_session_list(active)
829
+ for s in active:
830
+ show_session_detail(s)
831
+ sys.exit(0)
832
+
833
+ first_run = True
834
+ while True:
835
+ if first_run:
836
+ console.clear()
837
+ console.print(
838
+ f"[bold cyan]🤖 {__app_name__}[/] [dim]v{__version__}[/] · "
839
+ f"[dim]{__description__}[/] · [dim]© {__author__} 2026[/]"
840
+ )
841
+ first_run = False
842
+
843
+ sessions = load_all_sessions()
844
+ active_count = sum(1 for s in sessions if s["active"])
845
+
846
+ console.print(f"\n[dim]{len(sessions)} sessions, [green]{active_count} active[/][/]")
847
+ console.print()
848
+ console.print(" [cyan]l[/] List all sessions [cyan]f[/] Filter/search sessions")
849
+ console.print(" [cyan]a[/] Active sessions [cyan]v[/] View session detail + 💰 Usage")
850
+ console.print(" [cyan]s[/] Show statistics [cyan]d[/] Delete session(s)")
851
+ console.print(" [cyan]k[/] Kill live session(s) [cyan]c[/] Cleanup old sessions")
852
+ console.print(" [cyan]e[/] Export (CSV/JSON/HTML) [cyan]r[/] Refresh")
853
+ console.print(" [cyan]q[/] Quit")
854
+
855
+ cmd = Prompt.ask(f"\n[bold cyan]{__app_name__}[/]", choices=["l", "f", "a", "v", "d", "k", "s", "c", "e", "r", "q"], default="l")
856
+
857
+ if cmd == "q":
858
+ console.print("[dim]Goodbye! 👋[/]")
859
+ break
860
+
861
+ elif cmd == "l":
862
+ show_session_list(sessions)
863
+
864
+ elif cmd == "a":
865
+ active = [s for s in sessions if s["active"]]
866
+ if not active:
867
+ console.print("[yellow]No active sessions found.[/]")
868
+ continue
869
+ show_session_list(active)
870
+
871
+ elif cmd == "f":
872
+ term = Prompt.ask("[bold]Search term[/] (summary, id, path, repo, message)")
873
+ show_session_list(sessions, filter_text=term)
874
+
875
+ elif cmd == "v":
876
+ filtered = show_session_list(sessions)
877
+ if not filtered:
878
+ continue
879
+ try:
880
+ num = IntPrompt.ask(f"[bold]Session # to view[/] (1-{len(filtered)})")
881
+ if 1 <= num <= len(filtered):
882
+ show_session_detail(filtered[num - 1])
883
+ else:
884
+ console.print("[red]Invalid number.[/]")
885
+ except Exception:
886
+ pass
887
+
888
+ elif cmd == "d":
889
+ filtered = show_session_list(sessions)
890
+ if not filtered:
891
+ continue
892
+ raw = Prompt.ask(f"[bold]Session #(s) to delete[/] (comma-separated, e.g. 3,5,7)")
893
+ try:
894
+ indices = [int(x.strip()) - 1 for x in raw.split(",")]
895
+ valid = all(0 <= i < len(filtered) for i in indices)
896
+ if valid:
897
+ delete_sessions(filtered, indices)
898
+ else:
899
+ console.print("[red]Invalid number(s).[/]")
900
+ except ValueError:
901
+ console.print("[red]Invalid input.[/]")
902
+
903
+ elif cmd == "k":
904
+ kill_sessions(sessions)
905
+
906
+ elif cmd == "s":
907
+ show_stats(sessions)
908
+
909
+ elif cmd == "c":
910
+ cleanup_old_sessions(sessions)
911
+
912
+ elif cmd == "e":
913
+ export_sessions(sessions)
914
+
915
+ elif cmd == "r":
916
+ console.print("[dim]Refreshed.[/]")
917
+
918
+
919
+ if __name__ == "__main__":
920
+ main()
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: copilot-sessions
3
+ Version: 1.3.0
4
+ Summary: Terminal tool to view and manage GitHub Copilot CLI sessions
5
+ Author: Haim Cohen
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sk3pp3r/copilot-sessions
8
+ Project-URL: Issues, https://github.com/sk3pp3r/copilot-sessions/issues
9
+ Keywords: copilot,github,cli,sessions
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Environment :: Console
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: rich>=13.0.0
19
+ Dynamic: license-file
20
+
21
+ # 🤖 Copilot Sessions
22
+
23
+ A terminal tool to view and manage your GitHub Copilot CLI sessions — track usage, costs, premium requests, kill stale sessions, and clean up old ones.
24
+
25
+ ![Python](https://img.shields.io/badge/python-3.9+-blue)
26
+ ![License](https://img.shields.io/badge/license-MIT-green)
27
+
28
+ ## Features
29
+
30
+ - **List & filter** all Copilot CLI sessions
31
+ - **View details** with full 💰 usage breakdown (tokens, models, premium requests, cost)
32
+ - **Active sessions** — see what's running live
33
+ - **Kill** stale/orphan live sessions
34
+ - **Delete & cleanup** old sessions by age
35
+ - **Statistics** — total usage across all sessions
36
+ - **Resume command** — copy-paste to jump back into any session
37
+
38
+ ## Installation
39
+
40
+ ### pipx (Recommended)
41
+ ```bash
42
+ pipx install copilot-sessions
43
+ ```
44
+
45
+ ### pip
46
+ ```bash
47
+ pip install copilot-sessions
48
+ ```
49
+
50
+ ### From source
51
+ ```bash
52
+ git clone https://github.com/sk3pp3r/copilot-sessions.git
53
+ cd copilot-sessions
54
+ pipx install .
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ copilot-sessions # launch interactive manager
61
+ copilot-sessions --active # show active sessions only
62
+ copilot-sessions --version # show version
63
+ copilot-sessions --help # full help
64
+ ```
65
+
66
+ ### Interactive Commands
67
+
68
+ | Key | Action |
69
+ |-----|--------|
70
+ | `l` | List all sessions |
71
+ | `f` | Filter/search sessions |
72
+ | `a` | Active sessions |
73
+ | `v` | View session detail + 💰 Usage |
74
+ | `s` | Show statistics |
75
+ | `d` | Delete session(s) |
76
+ | `k` | Kill live session(s) |
77
+ | `c` | Cleanup old sessions |
78
+ | `r` | Refresh |
79
+ | `q` | Quit |
80
+
81
+ ## Requirements
82
+
83
+ - Python 3.9+
84
+ - [rich](https://github.com/Textualize/rich) (installed automatically)
85
+ - GitHub Copilot CLI (`~/.copilot/session-state/` must exist)
86
+
87
+ ## License
88
+
89
+ MIT © Haim Cohen
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/copilot_dashboard/__init__.py
5
+ src/copilot_dashboard/__main__.py
6
+ src/copilot_dashboard/main.py
7
+ src/copilot_sessions.egg-info/PKG-INFO
8
+ src/copilot_sessions.egg-info/SOURCES.txt
9
+ src/copilot_sessions.egg-info/dependency_links.txt
10
+ src/copilot_sessions.egg-info/entry_points.txt
11
+ src/copilot_sessions.egg-info/requires.txt
12
+ src/copilot_sessions.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ copilot-sessions = copilot_dashboard.main:main
@@ -0,0 +1 @@
1
+ copilot_dashboard