copilot-cli-trace-deck 0.1.0__py3-none-any.whl

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,315 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import threading
6
+ import time
7
+ import webbrowser
8
+ from http import HTTPStatus
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+ from pathlib import Path
11
+ from urllib.parse import parse_qs, urlsplit
12
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
13
+ from watchdog.observers import Observer
14
+
15
+ from ..data.sessions import load_session_flow, load_session_logs, load_session_previews, load_session_summary
16
+ from .pages import PAGE_TITLE, build_flow_feed_payload, build_flow_page, build_index_feed_payload, build_index_page, build_logs_feed_payload, build_logs_page, build_missing_session_page, build_session_page, build_session_snapshot_payload
17
+
18
+
19
+ DEFAULT_SESSION_ROOT = Path.home() / ".copilot" / "session-state"
20
+ RELEVANT_SESSION_FILES = {"events.jsonl", "workspace.yaml"}
21
+ SSE_HEARTBEAT_INTERVAL = 15.0
22
+
23
+
24
+ def build_server_url(host: str, port: int) -> str:
25
+ return f"http://{host}:{port}"
26
+
27
+
28
+ def serialize_payload(payload: dict[str, object]) -> str:
29
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True)
30
+
31
+
32
+ def is_relevant_session_path(raw_path: str, session_root: Path) -> bool:
33
+ if not raw_path:
34
+ return False
35
+
36
+ path = Path(raw_path).expanduser().resolve(strict=False)
37
+ normalized_root = session_root.expanduser().resolve(strict=False)
38
+ if path.name not in RELEVANT_SESSION_FILES:
39
+ return False
40
+
41
+ try:
42
+ path.relative_to(normalized_root)
43
+ except ValueError:
44
+ return False
45
+ return True
46
+
47
+
48
+ class SessionStateChangeHandler(FileSystemEventHandler):
49
+ def __init__(self, broadcaster: "SessionStateChangeBroadcaster") -> None:
50
+ self.broadcaster = broadcaster
51
+
52
+ def on_any_event(self, event: FileSystemEvent) -> None:
53
+ if event.is_directory:
54
+ return
55
+
56
+ src_path = getattr(event, "src_path", "")
57
+ dest_path = getattr(event, "dest_path", "")
58
+ if is_relevant_session_path(src_path, self.broadcaster.session_root) or is_relevant_session_path(dest_path, self.broadcaster.session_root):
59
+ self.broadcaster.notify_change()
60
+
61
+
62
+ class SessionStateChangeBroadcaster:
63
+ def __init__(self, session_root: Path) -> None:
64
+ self.session_root = session_root.expanduser()
65
+ self._condition = threading.Condition()
66
+ self._version = 0
67
+ self._observer: Observer | None = None
68
+
69
+ def start(self) -> None:
70
+ watch_root = self.session_root if self.session_root.exists() else self.session_root.parent
71
+ observer = Observer()
72
+ observer.schedule(SessionStateChangeHandler(self), str(watch_root), recursive=True)
73
+ observer.start()
74
+ self._observer = observer
75
+
76
+ def stop(self) -> None:
77
+ observer = self._observer
78
+ if observer is None:
79
+ return
80
+
81
+ observer.stop()
82
+ observer.join(timeout=5)
83
+ self._observer = None
84
+
85
+ def current_version(self) -> int:
86
+ with self._condition:
87
+ return self._version
88
+
89
+ def notify_change(self) -> None:
90
+ with self._condition:
91
+ self._version += 1
92
+ self._condition.notify_all()
93
+
94
+ def wait_for_change(self, version: int, timeout: float) -> int:
95
+ with self._condition:
96
+ if self._version != version:
97
+ return self._version
98
+
99
+ self._condition.wait(timeout=timeout)
100
+ return self._version
101
+
102
+
103
+ class TraceDeckHandler(BaseHTTPRequestHandler):
104
+ session_root = DEFAULT_SESSION_ROOT
105
+ change_broadcaster: SessionStateChangeBroadcaster | None = None
106
+
107
+ def handle(self) -> None:
108
+ try:
109
+ super().handle()
110
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
111
+ self.close_connection = True
112
+
113
+ def do_GET(self) -> None: # noqa: N802
114
+ parsed_url = urlsplit(self.path)
115
+ path = parsed_url.path
116
+ query = parse_qs(parsed_url.query)
117
+ if path == "/index.events":
118
+ self.respond_event_stream(lambda: serialize_payload(build_index_feed_payload(load_session_previews(self.session_root))))
119
+ return
120
+
121
+ if path == "/index.json":
122
+ self.respond_json(build_index_feed_payload(load_session_previews(self.session_root)), HTTPStatus.OK)
123
+ return
124
+
125
+ if path in {"/", "/index.html"}:
126
+ self.respond_html(build_index_page(load_session_previews(self.session_root)), HTTPStatus.OK)
127
+ return
128
+
129
+ path_parts = [part for part in path.split("/") if part]
130
+ if path_parts[:1] == ["sessions"]:
131
+ if len(path_parts) < 2:
132
+ self.send_error(HTTPStatus.NOT_FOUND, "Not Found")
133
+ return
134
+
135
+ session_id = path_parts[1]
136
+ summary = load_session_summary(self.session_root, session_id)
137
+ if summary is None:
138
+ self.respond_html(build_missing_session_page(session_id), HTTPStatus.NOT_FOUND)
139
+ return
140
+
141
+ if len(path_parts) == 3 and path_parts[2] == "logs":
142
+ log_entries = load_session_logs(self.session_root, session_id)
143
+ self.respond_html(build_logs_page(summary, log_entries or []), HTTPStatus.OK)
144
+ return
145
+
146
+ if len(path_parts) == 3 and path_parts[2] == "summary.events":
147
+ self.respond_event_stream(
148
+ lambda: serialize_payload(build_session_snapshot_payload(load_session_summary(self.session_root, session_id) or summary))
149
+ )
150
+ return
151
+
152
+ if len(path_parts) == 3 and path_parts[2] == "summary.json":
153
+ self.respond_json(build_session_snapshot_payload(summary), HTTPStatus.OK)
154
+ return
155
+
156
+ if len(path_parts) == 3 and path_parts[2] == "logs.events":
157
+ self.respond_event_stream(
158
+ lambda: serialize_payload(build_logs_feed_payload(load_session_logs(self.session_root, session_id) or [], -1))
159
+ )
160
+ return
161
+
162
+ if len(path_parts) == 3 and path_parts[2] == "logs.json":
163
+ after_value = query.get("after", ["-1"])[0]
164
+ try:
165
+ after_index = int(after_value)
166
+ except ValueError:
167
+ after_index = -1
168
+
169
+ log_entries = load_session_logs(self.session_root, session_id) or []
170
+ payload = build_logs_feed_payload(log_entries, after_index)
171
+ self.respond_json(payload, HTTPStatus.OK)
172
+ return
173
+
174
+ if len(path_parts) == 3 and path_parts[2] == "flow":
175
+ flow_nodes = load_session_flow(self.session_root, session_id)
176
+ self.respond_html(build_flow_page(summary, flow_nodes or []), HTTPStatus.OK)
177
+ return
178
+
179
+ if len(path_parts) == 3 and path_parts[2] == "flow.events":
180
+ self.respond_event_stream(
181
+ lambda: serialize_payload(
182
+ build_flow_feed_payload(
183
+ session_id,
184
+ load_session_summary(self.session_root, session_id) or summary,
185
+ load_session_flow(self.session_root, session_id) or [],
186
+ -1,
187
+ )
188
+ )
189
+ )
190
+ return
191
+
192
+ if len(path_parts) == 3 and path_parts[2] == "flow.json":
193
+ after_value = query.get("after", ["-1"])[0]
194
+ try:
195
+ after_index = int(after_value)
196
+ except ValueError:
197
+ after_index = -1
198
+
199
+ flow_nodes = load_session_flow(self.session_root, session_id) or []
200
+ payload = build_flow_feed_payload(session_id, summary, flow_nodes, after_index)
201
+ self.respond_json(payload, HTTPStatus.OK)
202
+ return
203
+
204
+ if len(path_parts) == 2:
205
+ self.respond_html(build_session_page(summary), HTTPStatus.OK)
206
+ return
207
+
208
+ self.send_error(HTTPStatus.NOT_FOUND, "Not Found")
209
+
210
+ def respond_html(self, page: str, status: HTTPStatus) -> None:
211
+ body = page.encode("utf-8")
212
+ self.send_response(status)
213
+ self.send_header("Content-Type", "text/html; charset=utf-8")
214
+ self.send_header("Content-Length", str(len(body)))
215
+ self.end_headers()
216
+ self.wfile.write(body)
217
+
218
+ def respond_json(self, payload: dict[str, object], status: HTTPStatus) -> None:
219
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
220
+ self.send_response(status)
221
+ self.send_header("Content-Type", "application/json; charset=utf-8")
222
+ self.send_header("Content-Length", str(len(body)))
223
+ self.end_headers()
224
+ self.wfile.write(body)
225
+
226
+ def respond_event_stream(self, snapshot_loader) -> None:
227
+ self.close_connection = True
228
+ self.send_response(HTTPStatus.OK)
229
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
230
+ self.send_header("Cache-Control", "no-cache")
231
+ self.send_header("Connection", "keep-alive")
232
+ self.send_header("X-Accel-Buffering", "no")
233
+ self.end_headers()
234
+
235
+ broadcaster = self.change_broadcaster
236
+ version = broadcaster.current_version() if broadcaster else 0
237
+ previous_snapshot = snapshot_loader()
238
+ last_heartbeat = time.monotonic()
239
+
240
+ try:
241
+ self.write_event_stream_message("update")
242
+ while True:
243
+ if broadcaster is None:
244
+ time.sleep(SSE_HEARTBEAT_INTERVAL)
245
+ self.write_event_stream_comment("keepalive")
246
+ last_heartbeat = time.monotonic()
247
+ continue
248
+
249
+ remaining = max(0.0, SSE_HEARTBEAT_INTERVAL - (time.monotonic() - last_heartbeat))
250
+ next_version = broadcaster.wait_for_change(version, remaining)
251
+ now = time.monotonic()
252
+ if next_version != version:
253
+ version = next_version
254
+ snapshot = snapshot_loader()
255
+ if snapshot != previous_snapshot:
256
+ self.write_event_stream_message("update")
257
+ previous_snapshot = snapshot
258
+ last_heartbeat = now
259
+ continue
260
+
261
+ if now - last_heartbeat >= SSE_HEARTBEAT_INTERVAL:
262
+ self.write_event_stream_comment("keepalive")
263
+ last_heartbeat = now
264
+ except (BrokenPipeError, ConnectionResetError, TimeoutError, ValueError):
265
+ return
266
+
267
+ def write_event_stream_comment(self, comment: str) -> None:
268
+ self.wfile.write(f": {comment}\n\n".encode("utf-8"))
269
+ self.wfile.flush()
270
+
271
+ def write_event_stream_message(self, data: str) -> None:
272
+ self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
273
+ self.wfile.flush()
274
+
275
+ def log_message(self, format: str, *args: object) -> None:
276
+ return
277
+
278
+
279
+ def parse_args() -> argparse.Namespace:
280
+ parser = argparse.ArgumentParser(description=PAGE_TITLE)
281
+ parser.add_argument("source", nargs="?", default=str(DEFAULT_SESSION_ROOT), help="Session-state root to read")
282
+ parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
283
+ parser.add_argument("--port", type=int, default=9887, help="Port to listen on")
284
+ parser.add_argument(
285
+ "--quiet",
286
+ "--quite",
287
+ action="store_true",
288
+ dest="quiet",
289
+ help="Suppress the startup banner and do not open a browser automatically",
290
+ )
291
+ return parser.parse_args()
292
+
293
+
294
+ def main() -> int:
295
+ args = parse_args()
296
+ TraceDeckHandler.session_root = Path(args.source).expanduser()
297
+ TraceDeckHandler.change_broadcaster = SessionStateChangeBroadcaster(TraceDeckHandler.session_root)
298
+ TraceDeckHandler.change_broadcaster.start()
299
+ server = ThreadingHTTPServer((args.host, args.port), TraceDeckHandler)
300
+ url = build_server_url(args.host, args.port)
301
+ if not args.quiet:
302
+ print(f"Serving {PAGE_TITLE} on {url}")
303
+ webbrowser.open(url)
304
+ else:
305
+ print(url)
306
+ try:
307
+ server.serve_forever()
308
+ except KeyboardInterrupt:
309
+ pass
310
+ finally:
311
+ server.server_close()
312
+ if TraceDeckHandler.change_broadcaster is not None:
313
+ TraceDeckHandler.change_broadcaster.stop()
314
+ TraceDeckHandler.change_broadcaster = None
315
+ return 0
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: copilot-cli-trace-deck
3
+ Version: 0.1.0
4
+ Summary: Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: watchdog<7,>=6
9
+ Dynamic: license-file
10
+
11
+ # copilot-cli-trace-deck
12
+ Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
13
+
14
+ ## Screenshots
15
+
16
+ ### Main View
17
+
18
+ ![Main view](./main.png)
19
+
20
+ ### Summary View
21
+
22
+ ![Summary view](./summary.png)
23
+
24
+ ### Log View
25
+
26
+ ![Log view](./log.png)
27
+
28
+ ### Flow View
29
+
30
+ ![Flow view](./flow.png)
31
+
32
+ ## Run
33
+
34
+ Run the app directly from the workspace with `uv`:
35
+
36
+ ```bash
37
+ uv run copilot-cli-trace-deck
38
+ ```
39
+
40
+ By default the server listens on `http://127.0.0.1:9887` and opens that URL in your browser.
41
+
42
+ Live updates are pushed over Server-Sent Events and triggered by file-system changes in the session-state directory, so the home, summary, logs, and flow pages update without browser polling.
43
+
44
+ You can also pass the session-state source and server options:
45
+
46
+ ```bash
47
+ uv run copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
48
+ ```
49
+
50
+ To skip opening a browser while still printing the local URL:
51
+
52
+ ```bash
53
+ uv run copilot-cli-trace-deck --quiet
54
+ ```
55
+
56
+ ## Install As A Command
57
+
58
+ Install the project as a local tool and run it directly from your shell:
59
+
60
+ ```bash
61
+ uv tool install .
62
+ copilot-cli-trace-deck --help
63
+ ```
@@ -0,0 +1,15 @@
1
+ copilot_cli_trace_deck/__init__.py,sha256=vbXuXZEIh4WLwR9bVHeD5aB6yXt7H7zDGwx_GWMSfGo,38
2
+ copilot_cli_trace_deck/__main__.py,sha256=UlvULmcpjPvRsgFaSDEc0343rMArSBe7ZeRrCcoIJyo,87
3
+ copilot_cli_trace_deck/models.py,sha256=ug7bi2mWN4EuARGITslcoS5jYKNWfowXru1MRCcndoc,1097
4
+ copilot_cli_trace_deck/server.py,sha256=E7lBuWEg_Ipbf2bMIAuTKpc_QqEW0rL8mptczToUVdo,159
5
+ copilot_cli_trace_deck/data/__init__.py,sha256=x4RE-d6qnTA7qPTDNb8dTdTBPBzBm38RTfyIWvRpqMk,164
6
+ copilot_cli_trace_deck/data/sessions.py,sha256=YtZ2oYm_8Ai2uuDD3OniVofGx3U5SoO54J-f_cCl6X0,23792
7
+ copilot_cli_trace_deck/web/__init__.py,sha256=gNE_oMKNIJOMXgtkP5Q_dzqJEW82LesNrYlqo434eNo,45
8
+ copilot_cli_trace_deck/web/pages.py,sha256=KAgDoXUD0sJQAqtcJg1InSrP1EENzTqlq4BFO8mu7hU,69531
9
+ copilot_cli_trace_deck/web/server.py,sha256=yZNCupg0Zsk2FhatOyVc9ZlfN8h5F8eZFB2gysxJlhk,12385
10
+ copilot_cli_trace_deck-0.1.0.dist-info/licenses/LICENSE,sha256=hYRaJ2ed2IMo9RmO96UxUSTt9iwL49leBY5kVtDymRo,1063
11
+ copilot_cli_trace_deck-0.1.0.dist-info/METADATA,sha256=Bk-t9y8WFHLgrhbVOLvF9KR0eqoeaNyyDB2e117Dg98,1412
12
+ copilot_cli_trace_deck-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ copilot_cli_trace_deck-0.1.0.dist-info/entry_points.txt,sha256=3uF4fO40GrZ5EDqNfJAR4fa3HuGnPk_ysx7vrCU8Nos,80
14
+ copilot_cli_trace_deck-0.1.0.dist-info/top_level.txt,sha256=_uXXkSLhf1rQn-WOeTo7ms2PCWcjRKGj5KCOQCJTJ6g,23
15
+ copilot_cli_trace_deck-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ copilot-cli-trace-deck = copilot_cli_trace_deck.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lanbao
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 @@
1
+ copilot_cli_trace_deck