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 @@
1
+ """Copilot CLI Trace Deck package."""
@@ -0,0 +1,5 @@
1
+ from .web.server import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Session data loading helpers."""
2
+
3
+ from .sessions import load_session_previews, load_session_summary
4
+
5
+ __all__ = ["load_session_previews", "load_session_summary"]
@@ -0,0 +1,628 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionPreview, SessionSummary
8
+
9
+
10
+ def load_session_previews(session_root: Path) -> list[SessionPreview]:
11
+ session_rows: list[tuple[str, SessionPreview]] = []
12
+ if not session_root.exists():
13
+ return []
14
+
15
+ for session_dir in session_root.iterdir():
16
+ if not session_dir.is_dir():
17
+ continue
18
+
19
+ metadata = read_workspace_metadata(session_dir / "workspace.yaml")
20
+ title = session_title_from_metadata(metadata)
21
+ if not title:
22
+ continue
23
+
24
+ updated_at = metadata.get("updated_at") or metadata.get("created_at") or ""
25
+ session_rows.append(
26
+ (
27
+ updated_at,
28
+ SessionPreview(session_id=session_dir.name, title=title),
29
+ )
30
+ )
31
+
32
+ session_rows.sort(key=lambda item: item[0], reverse=True)
33
+ previews = [session for _, session in session_rows]
34
+ if previews:
35
+ active = previews[0]
36
+ previews[0] = SessionPreview(session_id=active.session_id, title=active.title, is_active=True)
37
+ return previews
38
+
39
+
40
+ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary | None:
41
+ session_dir = session_root / session_id
42
+ if not session_dir.is_dir():
43
+ return None
44
+
45
+ metadata = read_workspace_metadata(session_dir / "workspace.yaml")
46
+ title = session_title_from_metadata(metadata)
47
+ if not title:
48
+ return None
49
+
50
+ events = read_jsonl_events(session_dir / "events.jsonl")
51
+ shutdown_event = next((event for event in reversed(events) if event.get("type") == "session.shutdown"), None)
52
+ model_name = find_current_model(events, shutdown_event)
53
+ usage = extract_usage(shutdown_event, model_name)
54
+ created_value = metadata.get("created_at") or first_event_timestamp(events)
55
+ updated_value = metadata.get("updated_at") or last_event_timestamp(events) or created_value
56
+
57
+ return SessionSummary(
58
+ session_id=session_id,
59
+ title=title,
60
+ created_label=format_timestamp(created_value),
61
+ updated_label=format_timestamp(updated_value),
62
+ session_type="Local",
63
+ location="CLI",
64
+ status="Idle" if shutdown_event else "Active",
65
+ model_name=model_name or "Unknown",
66
+ repository=repository_name(metadata.get("repository", "")),
67
+ branch=metadata.get("branch", ""),
68
+ model_turns=count_events(events, "assistant.turn_start"),
69
+ tool_calls=count_events(events, "tool.execution_start"),
70
+ total_input_tokens=usage.get("inputTokens", 0),
71
+ total_output_tokens=usage.get("outputTokens", 0),
72
+ total_cached_input_tokens=usage.get("cacheReadTokens", 0),
73
+ total_tokens=usage.get("inputTokens", 0) + usage.get("outputTokens", 0),
74
+ error_count=count_errors(events),
75
+ )
76
+
77
+
78
+ def load_session_logs(session_root: Path, session_id: str) -> list[SessionLogEntry] | None:
79
+ session_dir = session_root / session_id
80
+ if not session_dir.is_dir():
81
+ return None
82
+
83
+ events = read_jsonl_events(session_dir / "events.jsonl")
84
+ return [build_log_entry(index, event) for index, event in enumerate(events)]
85
+
86
+
87
+ def load_session_flow(session_root: Path, session_id: str) -> list[SessionFlowNode] | None:
88
+ session_dir = session_root / session_id
89
+ if not session_dir.is_dir():
90
+ return None
91
+
92
+ events = read_jsonl_events(session_dir / "events.jsonl")
93
+ shutdown_event = next((event for event in reversed(events) if event.get("type") == "session.shutdown"), None)
94
+ model_name = find_current_model(events, shutdown_event) or "Unknown"
95
+ return build_flow_nodes(events, model_name)
96
+
97
+
98
+ def read_workspace_metadata(workspace_file: Path) -> dict[str, str]:
99
+ if not workspace_file.exists():
100
+ return {}
101
+
102
+ metadata: dict[str, str] = {}
103
+ for raw_line in workspace_file.read_text(encoding="utf-8").splitlines():
104
+ key, separator, value = raw_line.partition(":")
105
+ if not separator:
106
+ continue
107
+ metadata[key.strip()] = value.strip()
108
+ return metadata
109
+
110
+
111
+ def session_title_from_metadata(metadata: dict[str, str]) -> str | None:
112
+ title = metadata.get("name", "").strip()
113
+ return title or None
114
+
115
+
116
+ def read_jsonl_events(events_file: Path) -> list[dict]:
117
+ if not events_file.exists():
118
+ return []
119
+
120
+ events: list[dict] = []
121
+ for raw_line in events_file.read_text(encoding="utf-8").splitlines():
122
+ if not raw_line.strip():
123
+ continue
124
+ try:
125
+ events.append(json.loads(raw_line))
126
+ except json.JSONDecodeError:
127
+ continue
128
+ return events
129
+
130
+
131
+ def find_current_model(events: list[dict], shutdown_event: dict | None) -> str:
132
+ if shutdown_event:
133
+ model_name = shutdown_event.get("data", {}).get("currentModel")
134
+ if isinstance(model_name, str) and model_name:
135
+ return model_name
136
+
137
+ for event in reversed(events):
138
+ if event.get("type") != "session.model_change":
139
+ continue
140
+ model_name = event.get("data", {}).get("newModel")
141
+ if isinstance(model_name, str) and model_name:
142
+ return model_name
143
+ return ""
144
+
145
+
146
+ def extract_usage(shutdown_event: dict | None, current_model: str) -> dict[str, int]:
147
+ if not shutdown_event:
148
+ return {}
149
+
150
+ model_metrics = shutdown_event.get("data", {}).get("modelMetrics", {})
151
+ if not isinstance(model_metrics, dict) or not model_metrics:
152
+ return {}
153
+
154
+ if current_model and current_model in model_metrics:
155
+ usage = model_metrics[current_model].get("usage", {})
156
+ else:
157
+ first_metrics = next(iter(model_metrics.values()))
158
+ usage = first_metrics.get("usage", {}) if isinstance(first_metrics, dict) else {}
159
+
160
+ if not isinstance(usage, dict):
161
+ return {}
162
+
163
+ return {
164
+ "inputTokens": int(usage.get("inputTokens", 0) or 0),
165
+ "outputTokens": int(usage.get("outputTokens", 0) or 0),
166
+ "cacheReadTokens": int(usage.get("cacheReadTokens", 0) or 0),
167
+ }
168
+
169
+
170
+ def count_events(events: list[dict], event_type: str) -> int:
171
+ return sum(1 for event in events if event.get("type") == event_type)
172
+
173
+
174
+ def count_errors(events: list[dict]) -> int:
175
+ return sum(1 for event in events if is_error_event(event))
176
+
177
+
178
+ def first_event_timestamp(events: list[dict]) -> str:
179
+ for event in events:
180
+ timestamp = event.get("timestamp")
181
+ if isinstance(timestamp, str) and timestamp:
182
+ return timestamp
183
+ return ""
184
+
185
+
186
+ def last_event_timestamp(events: list[dict]) -> str:
187
+ for event in reversed(events):
188
+ timestamp = event.get("timestamp")
189
+ if isinstance(timestamp, str) and timestamp:
190
+ return timestamp
191
+ return ""
192
+
193
+
194
+ def format_timestamp(raw_value: str) -> str:
195
+ if not raw_value:
196
+ return "-"
197
+ try:
198
+ dt = datetime.fromisoformat(raw_value.replace("Z", "+00:00")).astimezone()
199
+ except ValueError:
200
+ return raw_value
201
+
202
+ hour = dt.hour % 12 or 12
203
+ ampm = "AM" if dt.hour < 12 else "PM"
204
+ return f"{dt.month}/{dt.day}/{dt.year}, {hour}:{dt.minute:02d}:{dt.second:02d} {ampm}"
205
+
206
+
207
+ def repository_name(repository: str) -> str:
208
+ return repository.rsplit("/", 1)[-1] if repository else ""
209
+
210
+
211
+ def build_log_entry(index: int, event: dict) -> SessionLogEntry:
212
+ event_type = str(event.get("type") or "event")
213
+ data = event.get("data", {})
214
+ return SessionLogEntry(
215
+ index=index,
216
+ created_label=format_log_timestamp(str(event.get("timestamp") or "")),
217
+ name=display_event_name(event_type, data),
218
+ event_type=event_type,
219
+ details=display_event_details(event_type, data),
220
+ is_error=is_error_event(event),
221
+ sections=build_log_sections(event_type, event),
222
+ )
223
+
224
+
225
+ def is_error_event(event: dict) -> bool:
226
+ if event.get("type") == "abort":
227
+ return True
228
+
229
+ data = event.get("data", {})
230
+ return isinstance(data, dict) and data.get("success") is False
231
+
232
+
233
+ def format_log_timestamp(raw_value: str) -> str:
234
+ if not raw_value:
235
+ return "-"
236
+ try:
237
+ dt = datetime.fromisoformat(raw_value.replace("Z", "+00:00")).astimezone()
238
+ except ValueError:
239
+ return raw_value
240
+
241
+ hour = dt.hour % 12 or 12
242
+ ampm = "AM" if dt.hour < 12 else "PM"
243
+ return f"{dt.strftime('%b')} {dt.day}, {hour}:{dt.minute:02d}:{dt.second:02d} {ampm}"
244
+
245
+
246
+ def display_event_name(event_type: str, data: object) -> str:
247
+ if isinstance(data, dict):
248
+ tool_name = data.get("toolName")
249
+ if event_type.startswith("tool.execution") and isinstance(tool_name, str) and tool_name:
250
+ return tool_name
251
+
252
+ if event_type.endswith(".message"):
253
+ role = data.get("role")
254
+ if isinstance(role, str) and role:
255
+ return f"{role.title()} Message"
256
+
257
+ return humanize_identifier(event_type)
258
+
259
+
260
+ def display_event_details(event_type: str, data: object) -> str:
261
+ if not isinstance(data, dict):
262
+ return compact_text(pretty_value(data), 140)
263
+
264
+ if event_type == "session.start":
265
+ context = data.get("context") if isinstance(data.get("context"), dict) else {}
266
+ producer = compact_text(str(data.get("producer") or ""), 36)
267
+ cwd = compact_text(str(context.get("cwd") or ""), 72)
268
+ return join_non_empty([producer, cwd]) or "Session started"
269
+
270
+ if event_type == "session.model_change":
271
+ return f"Switched to {data.get('newModel') or 'unknown model'}"
272
+
273
+ if event_type.endswith(".message"):
274
+ content = data.get("content") or data.get("transformedContent") or ""
275
+ return compact_text(str(content), 140) or "Message payload"
276
+
277
+ if event_type == "assistant.turn_start":
278
+ return join_non_empty([f"turn {data.get('turnId')}" if data.get("turnId") is not None else "", str(data.get("interactionId") or "")]) or "Assistant turn started"
279
+
280
+ if event_type.startswith("tool.execution"):
281
+ tool_name = str(data.get("toolName") or "tool")
282
+ turn_id = f"turn {data.get('turnId')}" if data.get("turnId") is not None else ""
283
+ if event_type.endswith("complete"):
284
+ success = data.get("success")
285
+ state = "success" if success is True else "failed" if success is False else "completed"
286
+ return join_non_empty([tool_name, turn_id, state])
287
+ return join_non_empty([tool_name, turn_id, "started"])
288
+
289
+ fragments: list[str] = []
290
+ for key, value in data.items():
291
+ if key in {"arguments", "output", "content", "transformedContent"}:
292
+ continue
293
+ if isinstance(value, (dict, list)):
294
+ continue
295
+ fragments.append(f"{key}: {compact_text(str(value), 48)}")
296
+ if len(fragments) == 3:
297
+ break
298
+ return join_non_empty(fragments) or compact_text(pretty_value(data), 140) or "Event payload"
299
+
300
+
301
+ def build_log_sections(event_type: str, event: dict) -> list[SessionLogSection]:
302
+ data = event.get("data", {})
303
+ sections = [
304
+ SessionLogSection(
305
+ title="Metadata",
306
+ content=pretty_value(
307
+ {
308
+ "type": event_type,
309
+ "timestamp": event.get("timestamp"),
310
+ }
311
+ ),
312
+ )
313
+ ]
314
+
315
+ if isinstance(data, dict):
316
+ if "arguments" in data:
317
+ sections.append(SessionLogSection(title="Arguments", content=pretty_value(data.get("arguments"))))
318
+ if "output" in data:
319
+ sections.append(SessionLogSection(title="Output", content=pretty_value(data.get("output"))))
320
+ if "content" in data:
321
+ sections.append(SessionLogSection(title="Content", content=pretty_value(data.get("content"))))
322
+ if "transformedContent" in data:
323
+ sections.append(SessionLogSection(title="Transformed Content", content=pretty_value(data.get("transformedContent"))))
324
+ if "toolRequests" in data:
325
+ sections.append(SessionLogSection(title="Tool Requests", content=pretty_value(data.get("toolRequests"))))
326
+
327
+ remaining = {
328
+ key: value
329
+ for key, value in data.items()
330
+ if key not in {"arguments", "output", "content", "transformedContent", "toolRequests"}
331
+ }
332
+ if remaining:
333
+ sections.append(SessionLogSection(title="Data", content=pretty_value(remaining)))
334
+ elif data not in ({}, None, ""):
335
+ sections.append(SessionLogSection(title="Data", content=pretty_value(data)))
336
+
337
+ sections.append(SessionLogSection(title="Raw Event", content=pretty_value(event)))
338
+ return sections
339
+
340
+
341
+ def pretty_value(value: object) -> str:
342
+ if isinstance(value, str):
343
+ return value
344
+ return json.dumps(value, ensure_ascii=False, indent=2)
345
+
346
+
347
+ def compact_text(value: str, limit: int) -> str:
348
+ collapsed = " ".join(value.split())
349
+ if len(collapsed) <= limit:
350
+ return collapsed
351
+ return f"{collapsed[: limit - 1]}..."
352
+
353
+
354
+ def humanize_identifier(value: str) -> str:
355
+ parts = [part for part in value.replace("_", ".").replace("-", ".").split(".") if part]
356
+ return " ".join(part.capitalize() for part in parts) or "Event"
357
+
358
+
359
+ def join_non_empty(parts: list[str]) -> str:
360
+ return " | ".join(part for part in parts if part)
361
+
362
+
363
+ def build_flow_nodes(events: list[dict], model_name: str) -> list[SessionFlowNode]:
364
+ nodes: list[SessionFlowNode] = []
365
+ pending_tools: dict[str, dict] = {}
366
+
367
+ discovery_events, start_index = split_discovery_events(events)
368
+ if discovery_events:
369
+ nodes.append(build_discovery_node(0, discovery_events))
370
+
371
+ next_index = len(nodes)
372
+ for event_index, event in enumerate(events[start_index:], start=start_index):
373
+ event_type = str(event.get("type") or "event")
374
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
375
+
376
+ if event_type == "tool.execution_start":
377
+ tool_call_id = str(data.get("toolCallId") or "")
378
+ if tool_call_id:
379
+ pending_tools[tool_call_id] = data
380
+ continue
381
+
382
+ if event_type in {"hook.start", "hook.end", "assistant.turn_start", "assistant.turn_end", "session.start", "session.model_change", "system.message", "session.shutdown"}:
383
+ continue
384
+
385
+ if event_type == "user.message":
386
+ if is_internal_user_message(data):
387
+ continue
388
+ nodes.append(build_user_flow_node(next_index, event, event_index))
389
+ next_index += 1
390
+ continue
391
+
392
+ if event_type == "assistant.message":
393
+ assistant_nodes = build_assistant_flow_nodes(next_index, event, event_index, model_name)
394
+ nodes.extend(assistant_nodes)
395
+ next_index += len(assistant_nodes)
396
+ continue
397
+
398
+ if event_type == "tool.execution_complete":
399
+ tool_call_id = str(data.get("toolCallId") or "")
400
+ started = pending_tools.pop(tool_call_id, None)
401
+ nodes.append(build_tool_flow_node(next_index, event, event_index, started))
402
+ next_index += 1
403
+ continue
404
+
405
+ if event_type == "skill.invoked":
406
+ nodes.append(build_skill_flow_node(next_index, event, event_index))
407
+ next_index += 1
408
+ continue
409
+
410
+ if event_type == "subagent.selected":
411
+ nodes.append(build_subagent_flow_node(next_index, event, event_index))
412
+ next_index += 1
413
+ continue
414
+
415
+ if event_type.startswith("permission."):
416
+ nodes.append(build_permission_flow_node(next_index, event, event_index))
417
+ next_index += 1
418
+ continue
419
+
420
+ if event_type == "abort":
421
+ nodes.append(build_abort_flow_node(next_index, event, event_index))
422
+ next_index += 1
423
+
424
+ return nodes
425
+
426
+
427
+ def split_discovery_events(events: list[dict]) -> tuple[list[tuple[int, dict]], int]:
428
+ discovery_events: list[tuple[int, dict]] = []
429
+ index = 0
430
+ while index < len(events):
431
+ event = events[index]
432
+ event_type = str(event.get("type") or "event")
433
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
434
+ if event_type in {"session.start", "session.model_change", "subagent.selected", "system.message"}:
435
+ discovery_events.append((index, event))
436
+ index += 1
437
+ continue
438
+ if event_type == "user.message" and is_internal_user_message(data):
439
+ discovery_events.append((index, event))
440
+ index += 1
441
+ continue
442
+ break
443
+ return discovery_events, index
444
+
445
+
446
+ def build_discovery_node(index: int, events: list[tuple[int, dict]]) -> SessionFlowNode:
447
+ agent_name = next(
448
+ (
449
+ str((event.get("data") or {}).get("agentDisplayName") or (event.get("data") or {}).get("agentName") or "")
450
+ for _, event in events
451
+ if event.get("type") == "subagent.selected" and isinstance(event.get("data"), dict)
452
+ ),
453
+ "",
454
+ )
455
+ labels = [flow_event_label(event) for _, event in events]
456
+ detail = " · ".join(label for label in ([agent_name] if agent_name else []) + [label for label in labels if label][:3])
457
+ subtitle = f"{len(events)} discovery steps"
458
+ return SessionFlowNode(
459
+ index=index,
460
+ kind="group",
461
+ title="Agent Discovery",
462
+ subtitle=subtitle,
463
+ detail=detail,
464
+ meta=format_log_timestamp(str(events[0][1].get("timestamp") or "")),
465
+ log_index=events[0][0],
466
+ status="muted",
467
+ count=len(events),
468
+ )
469
+
470
+
471
+ def build_user_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
472
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
473
+ content = compact_text(str(data.get("content") or data.get("transformedContent") or "User input"), 220)
474
+ return SessionFlowNode(
475
+ index=index,
476
+ kind="user",
477
+ title="User Message",
478
+ subtitle=format_log_timestamp(str(event.get("timestamp") or "")),
479
+ detail=content,
480
+ meta="",
481
+ log_index=event_index,
482
+ status="accent",
483
+ )
484
+
485
+
486
+ def build_assistant_flow_nodes(index: int, event: dict, event_index: int, model_name: str) -> list[SessionFlowNode]:
487
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
488
+ output_tokens = int(data.get("outputTokens", 0) or 0)
489
+ tool_requests = data.get("toolRequests") if isinstance(data.get("toolRequests"), list) else []
490
+ content = str(data.get("content") or "").strip()
491
+ if not content:
492
+ content = f"Requested {len(tool_requests)} tool calls" if tool_requests else "Assistant emitted a structured response"
493
+ turn_id = data.get("turnId")
494
+ model_subtitle = ["assistant.message"]
495
+ if output_tokens:
496
+ model_subtitle.append(f"{output_tokens} output tokens")
497
+ model_subtitle.append(format_log_timestamp(str(event.get("timestamp") or "")))
498
+ model_detail = join_non_empty(
499
+ [
500
+ f"turn {turn_id}" if turn_id is not None else "",
501
+ f"{len(tool_requests)} tool requests" if tool_requests else "",
502
+ ]
503
+ ) or "Model turn completed"
504
+
505
+ return [
506
+ SessionFlowNode(
507
+ index=index,
508
+ kind="model",
509
+ title=model_name,
510
+ subtitle=join_non_empty(model_subtitle),
511
+ detail=model_detail,
512
+ meta="",
513
+ log_index=event_index,
514
+ status="accent",
515
+ ),
516
+ SessionFlowNode(
517
+ index=index + 1,
518
+ kind="response",
519
+ title="Agent Response",
520
+ subtitle=format_log_timestamp(str(event.get("timestamp") or "")),
521
+ detail=compact_text(content, 260),
522
+ meta="",
523
+ log_index=event_index,
524
+ status="neutral",
525
+ ),
526
+ ]
527
+
528
+
529
+ def build_tool_flow_node(index: int, event: dict, event_index: int, started: dict | None) -> SessionFlowNode:
530
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
531
+ started = started or {}
532
+ tool_name = str(started.get("toolName") or (data.get("toolTelemetry") or {}).get("displayTitle") or "Tool")
533
+ arguments = pretty_value(started.get("arguments")) if "arguments" in started else ""
534
+ result = data.get("result")
535
+ detail = compact_text(arguments or pretty_value(result), 220)
536
+ success = data.get("success") is not False
537
+ turn_id = started.get("turnId") or data.get("turnId")
538
+ subtitle_parts = ["success" if success else "failed"]
539
+ if turn_id is not None:
540
+ subtitle_parts.append(f"turn {turn_id}")
541
+ return SessionFlowNode(
542
+ index=index,
543
+ kind="tool",
544
+ title=tool_name,
545
+ subtitle=join_non_empty(subtitle_parts),
546
+ detail=detail or "Tool execution completed",
547
+ meta=format_log_timestamp(str(event.get("timestamp") or "")),
548
+ log_index=event_index,
549
+ status="success" if success else "error",
550
+ )
551
+
552
+
553
+ def build_skill_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
554
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
555
+ name = str(data.get("name") or "Skill")
556
+ description = compact_text(str(data.get("description") or "Skill invoked"), 220)
557
+ return SessionFlowNode(
558
+ index=index,
559
+ kind="skill",
560
+ title=name,
561
+ subtitle="skill.invoked",
562
+ detail=description,
563
+ meta=format_log_timestamp(str(event.get("timestamp") or "")),
564
+ log_index=event_index,
565
+ status="success",
566
+ )
567
+
568
+
569
+ def build_subagent_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
570
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
571
+ tools = data.get("tools") if isinstance(data.get("tools"), list) else []
572
+ tool_summary = f"{len(tools)} tools available" if tools else "Subagent selected"
573
+ return SessionFlowNode(
574
+ index=index,
575
+ kind="agent",
576
+ title=str(data.get("agentDisplayName") or data.get("agentName") or "Subagent"),
577
+ subtitle="subagent.selected",
578
+ detail=tool_summary,
579
+ meta=format_log_timestamp(str(event.get("timestamp") or "")),
580
+ log_index=event_index,
581
+ status="muted",
582
+ )
583
+
584
+
585
+ def build_permission_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
586
+ event_type = str(event.get("type") or "permission")
587
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
588
+ return SessionFlowNode(
589
+ index=index,
590
+ kind="state",
591
+ title=humanize_identifier(event_type),
592
+ subtitle=str(data.get("toolName") or event_type),
593
+ detail=compact_text(pretty_value(data), 180),
594
+ meta=format_log_timestamp(str(event.get("timestamp") or "")),
595
+ log_index=event_index,
596
+ status="muted",
597
+ )
598
+
599
+
600
+ def build_abort_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
601
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
602
+ return SessionFlowNode(
603
+ index=index,
604
+ kind="state",
605
+ title="Abort",
606
+ subtitle=str(data.get("reason") or "Session aborted"),
607
+ detail=compact_text(pretty_value(data), 180),
608
+ meta=format_log_timestamp(str(event.get("timestamp") or "")),
609
+ log_index=event_index,
610
+ status="error",
611
+ )
612
+
613
+
614
+ def flow_event_label(event: dict) -> str:
615
+ event_type = str(event.get("type") or "event")
616
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
617
+ if event_type == "subagent.selected":
618
+ return str(data.get("agentDisplayName") or data.get("agentName") or "Subagent")
619
+ if event_type == "session.model_change":
620
+ return str(data.get("newModel") or "Model selected")
621
+ if event_type == "user.message" and is_internal_user_message(data):
622
+ return "Skill Context"
623
+ return humanize_identifier(event_type)
624
+
625
+
626
+ def is_internal_user_message(data: dict) -> bool:
627
+ content = str(data.get("content") or "")
628
+ return content.lstrip().startswith("<skill-context")