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.
- copilot_sessions-1.3.0/LICENSE +21 -0
- copilot_sessions-1.3.0/PKG-INFO +89 -0
- copilot_sessions-1.3.0/README.md +69 -0
- copilot_sessions-1.3.0/pyproject.toml +32 -0
- copilot_sessions-1.3.0/setup.cfg +4 -0
- copilot_sessions-1.3.0/src/copilot_dashboard/__init__.py +3 -0
- copilot_sessions-1.3.0/src/copilot_dashboard/__main__.py +4 -0
- copilot_sessions-1.3.0/src/copilot_dashboard/main.py +920 -0
- copilot_sessions-1.3.0/src/copilot_sessions.egg-info/PKG-INFO +89 -0
- copilot_sessions-1.3.0/src/copilot_sessions.egg-info/SOURCES.txt +12 -0
- copilot_sessions-1.3.0/src/copilot_sessions.egg-info/dependency_links.txt +1 -0
- copilot_sessions-1.3.0/src/copilot_sessions.egg-info/entry_points.txt +2 -0
- copilot_sessions-1.3.0/src/copilot_sessions.egg-info/requires.txt +1 -0
- copilot_sessions-1.3.0/src/copilot_sessions.egg-info/top_level.txt +1 -0
|
@@ -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
|
+

|
|
26
|
+

|
|
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
|
+

|
|
6
|
+

|
|
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,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
|
+

|
|
26
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rich>=13.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
copilot_dashboard
|