agent-observatory-cli 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.
- agent_observatory_cli-0.1.0.dist-info/METADATA +115 -0
- agent_observatory_cli-0.1.0.dist-info/RECORD +31 -0
- agent_observatory_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_observatory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_observatory_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_observatory_cli-0.1.0.dist-info/top_level.txt +1 -0
- observatory/__init__.py +5 -0
- observatory/__main__.py +105 -0
- observatory/activity.css +10 -0
- observatory/activity.js +116 -0
- observatory/auto.py +212 -0
- observatory/client.py +253 -0
- observatory/core.py +1654 -0
- observatory/distribution.py +67 -0
- observatory/findings.py +176 -0
- observatory/index.html +84 -0
- observatory/integrations/__init__.py +1 -0
- observatory/integrations/langchain.py +234 -0
- observatory/integrations/openai_agents.py +177 -0
- observatory/local_activity.py +207 -0
- observatory/local_agents.py +394 -0
- observatory/local_runtime.py +49 -0
- observatory/logs.py +109 -0
- observatory/metrics.py +281 -0
- observatory/otlp_proto.py +210 -0
- observatory/server.py +368 -0
- observatory/setup.css +40 -0
- observatory/setup.js +145 -0
- observatory/terminal.py +525 -0
- observatory/terminal_ui.py +1299 -0
- observatory/watch.py +62 -0
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
"""Agent-first terminal UI. Raw evidence lives in scrollable inspectors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from rich.cells import cell_len, get_character_cell_size
|
|
16
|
+
from rich.segment import Segment
|
|
17
|
+
from rich.syntax import Syntax
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
from textual import on, work
|
|
21
|
+
from textual.app import App, ComposeResult
|
|
22
|
+
from textual.binding import Binding
|
|
23
|
+
from textual.containers import Horizontal, Vertical
|
|
24
|
+
from textual.screen import Screen
|
|
25
|
+
from textual.strip import Strip
|
|
26
|
+
from textual.widgets import Button, Checkbox, DataTable, Footer, Input, RichLog, Select, Static, TabbedContent, TabPane, Tree
|
|
27
|
+
|
|
28
|
+
from .terminal import clean, number, trace_name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cell(value, style=''):
|
|
32
|
+
return Text(clean(value), style=style, overflow='ellipsis', no_wrap=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def age(stamp):
|
|
36
|
+
if not stamp:
|
|
37
|
+
return 'unknown'
|
|
38
|
+
seconds=max(0,int(time.time()-float(stamp)/1e9))
|
|
39
|
+
return f'{seconds}s ago' if seconds<60 else f'{seconds//60}m ago' if seconds<3600 else f'{seconds//3600}h ago'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def timing(value):
|
|
43
|
+
try:
|
|
44
|
+
if value is None:
|
|
45
|
+
return 'not reported'
|
|
46
|
+
value=float(value)
|
|
47
|
+
return f'{value:.0f} ms' if value<1000 else f'{value/1000:.2f} s' if value<60000 else f'{value/60000:.1f} min'
|
|
48
|
+
except (ValueError,TypeError):
|
|
49
|
+
return 'not reported'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def quantity(value, singular, plural=None):
|
|
53
|
+
return f"{value} {singular if value==1 else (plural or singular+'s')}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def when(stamp):
|
|
57
|
+
value=time.localtime(stamp/1e9)
|
|
58
|
+
return time.strftime('%H:%M:%S' if value[:3]==time.localtime()[:3] else '%m-%d %H:%M',value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def table_when(stamp):
|
|
62
|
+
value=time.localtime(stamp/1e9)
|
|
63
|
+
return time.strftime('%H:%M:%S' if value[:3]==time.localtime()[:3] else '%m-%d',value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def status(value):
|
|
67
|
+
label={'OK':'OK','UNSET':'Observed','RUNNING':'In progress','ERROR':'Error','FAILED':'Failed','INCOMPLETE':'Incomplete',
|
|
68
|
+
'success':'OK','started':'Started','completed':'Completed','observed':'Observed','reported':'Reported','error':'Error'}.get(str(value),str(value).title())
|
|
69
|
+
color='#ff8b92' if str(value).lower() in {'error','failed','denied','blocked','incomplete'} else '#6ee7c2' if str(value).lower() in {'success','ok','completed'} else '#8bbdff' if str(value).lower() in {'running','started'} else '#8b9bb0'
|
|
70
|
+
return cell(label,color)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def decode(value):
|
|
74
|
+
# Local logs often wrap the same event in several JSON-encoded transport
|
|
75
|
+
# envelopes. Unwrap those so newlines and typed text blocks reach the UI.
|
|
76
|
+
for _ in range(24):
|
|
77
|
+
if not isinstance(value,str):
|
|
78
|
+
break
|
|
79
|
+
try:
|
|
80
|
+
parsed=json.loads(value)
|
|
81
|
+
except (ValueError,TypeError):
|
|
82
|
+
break
|
|
83
|
+
if isinstance(parsed,(dict,list)):
|
|
84
|
+
value=parsed
|
|
85
|
+
elif isinstance(parsed,str) and parsed.lstrip().startswith(('{','[')):
|
|
86
|
+
# Some event producers JSON-encode the payload, then JSON-encode
|
|
87
|
+
# that encoded string again inside their text block.
|
|
88
|
+
value=parsed
|
|
89
|
+
else:
|
|
90
|
+
break
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def decode_nested(value,depth=0):
|
|
95
|
+
"""Decode JSON strings inside arbitrarily nested telemetry fields."""
|
|
96
|
+
value=decode(value)
|
|
97
|
+
if depth>=64:return value
|
|
98
|
+
if isinstance(value,dict):
|
|
99
|
+
return {key:decode_nested(child,depth+1) for key,child in value.items()}
|
|
100
|
+
if isinstance(value,list):
|
|
101
|
+
return [decode_nested(child,depth+1) for child in value]
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def shell_command(value):
|
|
106
|
+
"""Remove the login-shell wrapper from commands captured from a PTY."""
|
|
107
|
+
command=str(value).strip()
|
|
108
|
+
try:
|
|
109
|
+
args=shlex.split(command)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return command
|
|
112
|
+
if len(args)>=3 and Path(args[0]).name in {'sh','bash','zsh','fish'} and args[1] in {'-c','-lc','-ilc'}:
|
|
113
|
+
return args[2]
|
|
114
|
+
return command
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def heredoc_script(command):
|
|
118
|
+
"""Return the language and body for a captured interpreter heredoc."""
|
|
119
|
+
lines=command.splitlines()
|
|
120
|
+
if len(lines)<3:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
header=shlex.split(lines[0])
|
|
124
|
+
except ValueError:
|
|
125
|
+
return None
|
|
126
|
+
interpreter=Path(header[0]).name.lower() if header else ''
|
|
127
|
+
delimiter_arg=next((part for part in header if part.startswith('<<')),None)
|
|
128
|
+
if not delimiter_arg:
|
|
129
|
+
return None
|
|
130
|
+
delimiter=delimiter_arg[2:].strip("'\"")
|
|
131
|
+
if not delimiter or lines[-1].strip()!=delimiter:
|
|
132
|
+
return None
|
|
133
|
+
if interpreter.startswith('python'):
|
|
134
|
+
language,title='python','Python script'
|
|
135
|
+
elif interpreter in {'node','nodejs','deno','bun'}:
|
|
136
|
+
language,title='javascript','JavaScript script'
|
|
137
|
+
elif interpreter in {'bash','sh','zsh','fish'}:
|
|
138
|
+
language,title='bash','Shell script'
|
|
139
|
+
else:
|
|
140
|
+
language,title='text',f'{interpreter or "Interpreter"} script'
|
|
141
|
+
return language,title,'\n'.join(lines[1:-1])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def embedded_json(text):
|
|
145
|
+
"""Split prose from complete JSON objects embedded in a text record."""
|
|
146
|
+
decoder=json.JSONDecoder()
|
|
147
|
+
segments=[]
|
|
148
|
+
cursor=0
|
|
149
|
+
index=0
|
|
150
|
+
while index<len(text):
|
|
151
|
+
if text[index] not in '{[':
|
|
152
|
+
index+=1
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
value,end=decoder.raw_decode(text,index)
|
|
156
|
+
except (ValueError,TypeError):
|
|
157
|
+
index+=1
|
|
158
|
+
continue
|
|
159
|
+
if not isinstance(value,(dict,list)) or end<=cursor:
|
|
160
|
+
index+=1
|
|
161
|
+
continue
|
|
162
|
+
if text[cursor:index]:segments.append(('text',text[cursor:index]))
|
|
163
|
+
segments.append(('json',value))
|
|
164
|
+
cursor=end
|
|
165
|
+
index=end
|
|
166
|
+
if not segments:return [('text',text)]
|
|
167
|
+
if text[cursor:]:segments.append(('text',text[cursor:]))
|
|
168
|
+
return segments
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def format_json(value):
|
|
172
|
+
"""Return indented JSON and multiline string fields for readable display."""
|
|
173
|
+
text_values=[]
|
|
174
|
+
def prepare(item,path=''):
|
|
175
|
+
if isinstance(item,dict):
|
|
176
|
+
return {key:prepare(child,f'{path}.{key}' if path else str(key)) for key,child in item.items()}
|
|
177
|
+
if isinstance(item,list):
|
|
178
|
+
return [prepare(child,f'{path}[{index}]') for index,child in enumerate(item)]
|
|
179
|
+
if isinstance(item,str) and '\n' in item:
|
|
180
|
+
text_values.append((path or 'Text value',item))
|
|
181
|
+
line_count=len(item.splitlines())
|
|
182
|
+
return f'[multiline text · {line_count} lines shown below]'
|
|
183
|
+
return item
|
|
184
|
+
|
|
185
|
+
return json.dumps(prepare(value),ensure_ascii=False,indent=2),text_values
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def write_json(log,value):
|
|
189
|
+
"""Render indented JSON and move multiline string values into readable blocks."""
|
|
190
|
+
formatted,text_values=format_json(value)
|
|
191
|
+
log.write(Syntax(formatted,'json',word_wrap=True,background_color='#101720'))
|
|
192
|
+
for path,content in text_values:
|
|
193
|
+
log.write(Text(f'\n{path} · {len(content.splitlines())} lines',style='bold #92b9eb'))
|
|
194
|
+
log.write(Text(content))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def copy_event_text(item):
|
|
198
|
+
"""Build a readable plain-text export of one selected activity or MCP event."""
|
|
199
|
+
is_mcp=bool(item.get('server') or item.get('tool') or item.get('domain')=='mcp')
|
|
200
|
+
title=f"{item.get('server') or 'MCP'} / {item.get('tool') or 'tool'}" if is_mcp else headline(item)
|
|
201
|
+
lines=[title,f"Agent: {item.get('service') or 'Not reported'}",
|
|
202
|
+
f"Type: {item.get('domain') or ('mcp' if is_mcp else 'Not reported')}",
|
|
203
|
+
f"Result: {item.get('result') or item.get('status') or 'Not reported'}",
|
|
204
|
+
f"When: {when(item['time_unix_nano']) if item.get('time_unix_nano') else 'Not reported'}",
|
|
205
|
+
f"Duration: {timing(item.get('duration_ms'))}"]
|
|
206
|
+
if item.get('trace_id'):lines.append(f"Trace ID: {item['trace_id']}")
|
|
207
|
+
if item.get('span_id'):lines.append(f"Span ID: {item['span_id']}")
|
|
208
|
+
lines.append('\nRecorded content')
|
|
209
|
+
def append_json(value):
|
|
210
|
+
formatted,text_values=format_json(value)
|
|
211
|
+
lines.append(formatted)
|
|
212
|
+
for path,content in text_values:lines.extend((f'\n{path}',content))
|
|
213
|
+
if is_mcp:
|
|
214
|
+
details={key:item.get(key) for key in ('server','tool','method','arguments','output') if item.get(key) is not None}
|
|
215
|
+
else:
|
|
216
|
+
details=decode_nested(item.get('detail') or '')
|
|
217
|
+
if isinstance(details,(dict,list)):
|
|
218
|
+
append_json(details)
|
|
219
|
+
elif isinstance(details,str):
|
|
220
|
+
for kind,content in embedded_json(details):
|
|
221
|
+
if kind=='json':append_json(content)
|
|
222
|
+
elif content.strip():lines.append(content.strip('\n'))
|
|
223
|
+
elif details:
|
|
224
|
+
lines.append(str(details))
|
|
225
|
+
return '\n'.join(lines)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def write_system_clipboard(text):
|
|
229
|
+
"""Write to the desktop clipboard when a native clipboard tool is available."""
|
|
230
|
+
candidates=[]
|
|
231
|
+
if sys.platform=='darwin' and shutil.which('pbcopy'):
|
|
232
|
+
candidates.append((['pbcopy'],'macOS'))
|
|
233
|
+
elif os.name=='nt' and shutil.which('clip.exe'):
|
|
234
|
+
candidates.append((['clip.exe'],'Windows'))
|
|
235
|
+
elif sys.platform.startswith('linux'):
|
|
236
|
+
if os.environ.get('WAYLAND_DISPLAY') and shutil.which('wl-copy'):
|
|
237
|
+
candidates.append((['wl-copy'],'Wayland'))
|
|
238
|
+
if os.environ.get('DISPLAY') and shutil.which('xclip'):
|
|
239
|
+
candidates.append((['xclip','-selection','clipboard','-i'],'X11'))
|
|
240
|
+
if os.environ.get('DISPLAY') and shutil.which('xsel'):
|
|
241
|
+
candidates.append((['xsel','--clipboard','--input'],'X11'))
|
|
242
|
+
for command,label in candidates:
|
|
243
|
+
try:
|
|
244
|
+
# Clipboard owners such as xclip fork a background process. Captured
|
|
245
|
+
# pipes can stay open in that child even after the command succeeds.
|
|
246
|
+
process=await asyncio.create_subprocess_exec(*command,stdin=subprocess.PIPE,
|
|
247
|
+
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
|
248
|
+
except OSError:
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
await asyncio.wait_for(process.communicate(text.encode('utf-8')),timeout=3)
|
|
252
|
+
except (asyncio.TimeoutError,asyncio.CancelledError) as exc:
|
|
253
|
+
if process.returncode is None:
|
|
254
|
+
try:process.kill()
|
|
255
|
+
except ProcessLookupError:pass
|
|
256
|
+
await process.wait()
|
|
257
|
+
if isinstance(exc,asyncio.CancelledError):raise
|
|
258
|
+
continue
|
|
259
|
+
if process.returncode==0:
|
|
260
|
+
return label
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def headline(item):
|
|
265
|
+
domain=item.get('domain') or 'tool'
|
|
266
|
+
subject=str(item.get('subject') or item.get('action') or 'Observed activity')
|
|
267
|
+
if domain=='tool' and subject.casefold() in {'exec','execute','shell'}:
|
|
268
|
+
details=decode_nested(item.get('detail') or '')
|
|
269
|
+
def find_command(value):
|
|
270
|
+
if isinstance(value,dict):
|
|
271
|
+
for key,child in value.items():
|
|
272
|
+
if str(key).casefold() in {'command','cmd'} and isinstance(child,str):
|
|
273
|
+
return child
|
|
274
|
+
for child in value.values():
|
|
275
|
+
found=find_command(child)
|
|
276
|
+
if found:return found
|
|
277
|
+
elif isinstance(value,list):
|
|
278
|
+
for child in value:
|
|
279
|
+
found=find_command(child)
|
|
280
|
+
if found:return found
|
|
281
|
+
return None
|
|
282
|
+
command=find_command(details)
|
|
283
|
+
if command:
|
|
284
|
+
command=shell_command(command)
|
|
285
|
+
script=heredoc_script(command)
|
|
286
|
+
if script:return script[1]
|
|
287
|
+
first=command.splitlines()[0] if command.splitlines() else command
|
|
288
|
+
return clean(first)
|
|
289
|
+
if domain=='shell':
|
|
290
|
+
try:
|
|
291
|
+
args=shlex.split(subject)
|
|
292
|
+
if len(args)>=3 and args[1] in {'-lc','-c','-ilc'}:
|
|
293
|
+
subject=args[2]
|
|
294
|
+
except ValueError:
|
|
295
|
+
pass
|
|
296
|
+
first=subject.splitlines()[0] if subject.splitlines() else 'Shell command'
|
|
297
|
+
if ' - <<' in first and first.startswith(('python','python3')):
|
|
298
|
+
return 'Run Python script'
|
|
299
|
+
return clean(first)
|
|
300
|
+
if domain=='file':
|
|
301
|
+
return f"{str(item.get('action') or 'Change').title()} {Path(subject).name}"
|
|
302
|
+
if domain=='mcp':
|
|
303
|
+
return f"{item.get('mcp_server') or subject} / {item.get('mcp_tool') or item.get('action')}"
|
|
304
|
+
if domain=='message':
|
|
305
|
+
return ('User: ' if item.get('action')=='user' else 'Agent: ')+subject
|
|
306
|
+
if domain=='model':
|
|
307
|
+
return 'Model usage · '+subject
|
|
308
|
+
if domain=='agent':
|
|
309
|
+
return {'processing':'Model processing','started':'Turn started','completed':'Turn completed','interrupted':'Turn interrupted','compaction':'Context compacted'}.get(item.get('action'),subject)
|
|
310
|
+
return subject if item.get('action') in {'call','result','execute_tool'} else f"{item.get('action') or domain} · {subject}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def sources(snapshot, include_reviews=False):
|
|
314
|
+
connections={item['source_id']:item for item in snapshot.agents}
|
|
315
|
+
result=[]
|
|
316
|
+
for entry in snapshot.local_status.get('sessions',[]):
|
|
317
|
+
connection=connections.pop(entry['source_id'],{})
|
|
318
|
+
review=entry.get('service')=='codex-review' or entry.get('model')=='codex-auto-review'
|
|
319
|
+
if review and not include_reviews:
|
|
320
|
+
continue
|
|
321
|
+
last=entry.get('last_activity') or {}
|
|
322
|
+
stamp=last.get('time_unix_nano') or 0
|
|
323
|
+
recent=bool(last and time.time()-stamp/1e9<35)
|
|
324
|
+
result.append({**entry,'id':entry['source_id'],'review':review,'recent':recent,'stamp':stamp,
|
|
325
|
+
'state':'Recent' if recent else 'Idle','spans':connection.get('span_count',0)})
|
|
326
|
+
for entry in connections.values():
|
|
327
|
+
if 'local-session-log' in entry.get('integrations',[]):
|
|
328
|
+
continue
|
|
329
|
+
result.append({**entry,'id':entry['source_id'],'session_id':None,'project':'SDK / OTLP','model':'',
|
|
330
|
+
'runtime':entry.get('service'),'task':'Task not reported','recent':entry['state']=='running',
|
|
331
|
+
'state':'Connected' if entry['state']=='running' else 'Finished' if entry['state']=='stopped' else 'Offline',
|
|
332
|
+
'stamp':entry.get('last_seen_ns',0),'spans':entry.get('span_count',0),'review':False})
|
|
333
|
+
return sorted(result,key=lambda item:(not item['recent'],-item['stamp'],item['id']))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class ResultsTable(DataTable):
|
|
337
|
+
"""Size columns to the panel while keeping the selected record on resize."""
|
|
338
|
+
def configure(self, columns):
|
|
339
|
+
self.column_specs=columns
|
|
340
|
+
self.column_widths=()
|
|
341
|
+
self.fit_columns()
|
|
342
|
+
|
|
343
|
+
def on_resize(self):
|
|
344
|
+
self.fit_columns()
|
|
345
|
+
|
|
346
|
+
def fit_columns(self):
|
|
347
|
+
specs=getattr(self,'column_specs',None)
|
|
348
|
+
if not specs:return
|
|
349
|
+
available=max(0,self.scrollable_content_region.width-2*len(specs)-1)
|
|
350
|
+
widths=[minimum for _,minimum,_ in specs]
|
|
351
|
+
spare=max(0,available-sum(widths))
|
|
352
|
+
weight=sum(weight for _,_,weight in specs)
|
|
353
|
+
for index,(_,_,share) in enumerate(specs):
|
|
354
|
+
widths[index]+=int(spare*share/weight)
|
|
355
|
+
widths[max(range(len(specs)),key=lambda i:specs[i][2])]+=max(0,available-sum(widths))
|
|
356
|
+
widths=tuple(widths)
|
|
357
|
+
if widths==self.column_widths:return
|
|
358
|
+
rows=[(key,self.get_row(key)) for key in self.rows]
|
|
359
|
+
selected=self.cursor_row
|
|
360
|
+
scroll=self.scroll_offset
|
|
361
|
+
self.clear(columns=True)
|
|
362
|
+
for index,((label,_,_),width) in enumerate(zip(specs,widths)):
|
|
363
|
+
self.add_column(label,width=width,key=str(index))
|
|
364
|
+
for key,values in rows:self.add_row(*values,key=key)
|
|
365
|
+
self.column_widths=widths
|
|
366
|
+
if rows:self.move_cursor(row=min(selected,len(rows)-1),scroll=False)
|
|
367
|
+
self.scroll_to(x=0,y=scroll.y,animate=False)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class SearchInput(Input):
|
|
371
|
+
"""Use familiar select-all behavior in dashboard search fields."""
|
|
372
|
+
BINDINGS=[Binding('ctrl+a','select_all','Select all',show=False)]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class SelectableRichLog(RichLog):
|
|
376
|
+
"""Selectable evidence with visible highlighting and plain-text extraction."""
|
|
377
|
+
def render_line(self,y):
|
|
378
|
+
source_y=self.scroll_offset.y+y
|
|
379
|
+
width=self.scrollable_content_region.width
|
|
380
|
+
if source_y>=len(self.lines):
|
|
381
|
+
# Map empty space to the end, so dragging below the text is safe.
|
|
382
|
+
last=len(self.lines)-1
|
|
383
|
+
return Strip.blank(width,self.rich_style).apply_offsets(
|
|
384
|
+
len(self.lines[last].text) if last>=0 else 0,max(0,last))
|
|
385
|
+
line=self.lines[source_y].apply_style(self.rich_style)
|
|
386
|
+
selection=self.text_selection
|
|
387
|
+
span=selection.get_span(source_y) if selection else None
|
|
388
|
+
if span:
|
|
389
|
+
start,end=span
|
|
390
|
+
text=line.text
|
|
391
|
+
start=cell_len(text[:start])
|
|
392
|
+
end=line.cell_length if end==-1 else cell_len(text[:end])
|
|
393
|
+
if end>start:
|
|
394
|
+
selected=line.crop(start,end)
|
|
395
|
+
highlight=self.screen.get_component_rich_style('screen--selection')
|
|
396
|
+
selected=Strip(Segment.apply_style(selected,post_style=highlight),selected.cell_length)
|
|
397
|
+
line=Strip.join([line.crop(0,start),selected,line.crop(end)])
|
|
398
|
+
cells=characters=0
|
|
399
|
+
for character in line.text:
|
|
400
|
+
if cells>=self.scroll_offset.x:break
|
|
401
|
+
cells+=get_character_cell_size(character)
|
|
402
|
+
characters+=1
|
|
403
|
+
return line.crop_extend(self.scroll_offset.x,self.scroll_offset.x+width,self.rich_style).apply_offsets(characters,source_y)
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def plain_text(self):
|
|
407
|
+
return '\n'.join(line.text.rstrip() for line in self.lines)
|
|
408
|
+
|
|
409
|
+
def get_selection(self,selection):
|
|
410
|
+
if not self.lines:return None
|
|
411
|
+
selected=[]
|
|
412
|
+
for y,line in enumerate(self.lines):
|
|
413
|
+
span=selection.get_span(y)
|
|
414
|
+
if span is not None:
|
|
415
|
+
start,end=span
|
|
416
|
+
selected.append(line.text[start:None if end==-1 else end].rstrip())
|
|
417
|
+
return '\n'.join(selected),'\n'
|
|
418
|
+
|
|
419
|
+
def clear(self):
|
|
420
|
+
# A selection from a previous event must never copy the next event.
|
|
421
|
+
if self.is_mounted and self in self.screen.selections:
|
|
422
|
+
self.screen.clear_selection()
|
|
423
|
+
return super().clear()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class ObservatoryScreen(Screen):
|
|
427
|
+
"""Copy pointer-selected text through the desktop clipboard when possible."""
|
|
428
|
+
def action_copy_text(self):
|
|
429
|
+
selected=self.get_selected_text()
|
|
430
|
+
if selected is None:
|
|
431
|
+
self.app.notify('Drag across text to select it, then press Ctrl+C.')
|
|
432
|
+
return
|
|
433
|
+
self.app.copy_to_clipboard(selected)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class ObservatoryApp(App):
|
|
437
|
+
TITLE='Agent Observatory'
|
|
438
|
+
AUTO_FOCUS='#agent-table'
|
|
439
|
+
CSS='''
|
|
440
|
+
Screen { background: #0d1117; color: #dce4ed; }
|
|
441
|
+
Screen > .screen--selection { background: #315a73; color: #ffffff; }
|
|
442
|
+
* { scrollbar-color: #405566; scrollbar-color-hover: #6b8995; scrollbar-color-active: #6ee7c2; scrollbar-background: #101720; scrollbar-background-hover: #101720; scrollbar-background-active: #101720; }
|
|
443
|
+
#masthead { height: 2; padding: 0 2; align-vertical: middle; }
|
|
444
|
+
#brand { width: 1fr; color: #e6edf3; text-style: bold; text-wrap: nowrap; }
|
|
445
|
+
#connection { width: auto; color: #6ee7c2; text-align: right; text-wrap: nowrap; }
|
|
446
|
+
#summary { height: 3; margin: 0 2 1 2; }
|
|
447
|
+
.stat { width: 1fr; border-left: solid #303b4b; padding: 0 2; text-wrap: nowrap; text-overflow: ellipsis; }
|
|
448
|
+
#stat-sessions { border-left: solid #6ee7c2; }
|
|
449
|
+
#controls { height: 3; margin: 0 2; align-vertical: middle; }
|
|
450
|
+
#scope-label { width: 7; height: 1; margin-top: 1; color: #8b9bb0; }
|
|
451
|
+
#scope { width: 37; margin-right: 1; }
|
|
452
|
+
SelectCurrent { background: #161e29; border: tall #263345; }
|
|
453
|
+
Select:focus > SelectCurrent { border: tall #6ee7c2; background-tint: transparent; }
|
|
454
|
+
SelectOverlay { background: #161e29; border: tall #6ee7c2; }
|
|
455
|
+
#controls-spacer { width: 1fr; }
|
|
456
|
+
#reviews { width: 14; border: none; background: #0d1117; margin-top: 1; color: #8b9bb0; }
|
|
457
|
+
Checkbox > .toggle--button { color: #56667b; background: #161e29; }
|
|
458
|
+
Checkbox.-on > .toggle--button { color: #6ee7c2; }
|
|
459
|
+
Button { height: 3; min-width: 9; background: #161e29; color: #b5c6d9; border: tall #263345; text-style: none; }
|
|
460
|
+
Button:hover { background: #213345; border: tall #6ee7c2; }
|
|
461
|
+
Button:focus { border: tall #6ee7c2; text-style: bold; }
|
|
462
|
+
Button:disabled { color: #56667b; border: tall #1d2734; background: #101720; }
|
|
463
|
+
#pause { width: 12; }
|
|
464
|
+
#pages { height: 1fr; margin: 0 2; }
|
|
465
|
+
Tabs { height: 3; padding-top: 1; }
|
|
466
|
+
Tab { color: #8b9bb0; padding: 0 2; }
|
|
467
|
+
Tab.-active { color: #6ee7c2; text-style: bold; }
|
|
468
|
+
Tabs:focus .-active { background: #183a35; color: #9ff3d9; }
|
|
469
|
+
Underline > .underline--bar { color: #6ee7c2; background: #263345; }
|
|
470
|
+
TabPane { padding: 0; }
|
|
471
|
+
ContentSwitcher { height: 1fr; }
|
|
472
|
+
.hint { height: 2; color: #8b9bb0; }
|
|
473
|
+
.box { border: round #303b4b; border-title-color: #b5c6d9; border-title-style: bold; background: #101720; padding: 0 1; }
|
|
474
|
+
.box:focus-within { border: round #537784; border-title-color: #6ee7c2; }
|
|
475
|
+
.results { height: 1fr; }
|
|
476
|
+
.searchbar { height: 3; align-vertical: middle; }
|
|
477
|
+
.view-search { width: 1fr; margin-right: 1; padding: 0 1; background: #161e29; border: tall #263345; }
|
|
478
|
+
.view-search:focus { border: tall #6ee7c2; background-tint: transparent; }
|
|
479
|
+
Input > .input--placeholder { color: #8b9bb0; }
|
|
480
|
+
Input > .input--cursor { background: #6ee7c2; color: #0d1117; }
|
|
481
|
+
Input > .input--selection { background: #315a73; color: #ffffff; }
|
|
482
|
+
.clear-search { width: 9; }
|
|
483
|
+
.error-filter { width: 12; margin-right: 1; }
|
|
484
|
+
.error-filter.-on { color: #ff8b92; border: tall #ff8b92; background: #2d2028; }
|
|
485
|
+
.search-status { height: 1; color: #8b9bb0; padding: 0 1; }
|
|
486
|
+
.history-controls { height: 1; margin-top: 1; }
|
|
487
|
+
.history-controls Static { width: 1fr; color: #8b9bb0; }
|
|
488
|
+
.history-controls Button { min-width: 8; width: auto; height: 1; margin-left: 1; border: none; }
|
|
489
|
+
.history-controls Button:focus { background: #203d47; color: #ffffff; }
|
|
490
|
+
#history-back { width: 10; margin-right: 1; }
|
|
491
|
+
DataTable { background: #101720; height: 1fr; scrollbar-size: 1 1; }
|
|
492
|
+
DataTable:focus { background-tint: transparent; }
|
|
493
|
+
DataTable > .datatable--header { background: #1a2533; color: #a4b7cb; text-style: bold; }
|
|
494
|
+
DataTable > .datatable--cursor { background: #203d47; color: #eefdf8; text-style: bold; }
|
|
495
|
+
DataTable:blur > .datatable--cursor { background: #1d2d3b; color: #dce4ed; text-style: none; }
|
|
496
|
+
DataTable > .datatable--hover { background: #1a2b38; }
|
|
497
|
+
DataTable > .datatable--odd-row { background: #131d29; }
|
|
498
|
+
DataTable > .datatable--even-row { background: #101720; }
|
|
499
|
+
RichLog { background: #101720; color: #dce4ed; scrollbar-size: 1 1; }
|
|
500
|
+
RichLog:focus { background-tint: transparent; }
|
|
501
|
+
#agent-table { height: 1fr; min-height: 3; }
|
|
502
|
+
#agent-bottom { height: 14; margin-top: 1; }
|
|
503
|
+
#agent-info { width: 1fr; margin-right: 1; }
|
|
504
|
+
#agent-detail { width: 100%; height: 1fr; }
|
|
505
|
+
#agent-open { height: 3; width: 100%; }
|
|
506
|
+
#agent-actions { width: 1fr; }
|
|
507
|
+
.split { height: 1fr; }
|
|
508
|
+
.list { width: 3fr; margin-right: 1; }
|
|
509
|
+
.detail { width: 2fr; }
|
|
510
|
+
.detail-log { height: 1fr; }
|
|
511
|
+
.detail-button { height: 3; width: 100%; }
|
|
512
|
+
.inspector-actions { height: 3; }
|
|
513
|
+
.inspector-actions Button { width: 1fr; }
|
|
514
|
+
.expand-button { min-width: 12; }
|
|
515
|
+
#trace-detail { width: 2fr; }
|
|
516
|
+
#span-tree { height: 1fr; min-height: 4; background: #101720; scrollbar-size: 1 1; }
|
|
517
|
+
#span-info { height: 10; border-top: solid #303b4b; }
|
|
518
|
+
#health { height: 1; padding: 0 2; color: #8b9bb0; }
|
|
519
|
+
Footer { background: #161e29; color: #8b9bb0; }
|
|
520
|
+
FooterKey { background: #20313e; color: #6ee7c2; }
|
|
521
|
+
FooterKey > .footer-key--key { background: #20313e; color: #6ee7c2; }
|
|
522
|
+
FooterKey > .footer-key--description { background: #161e29; color: #b5c6d9; }
|
|
523
|
+
FooterLabel { background: #161e29; color: #b5c6d9; }
|
|
524
|
+
.narrow #masthead { padding: 0 1; }
|
|
525
|
+
.narrow #summary { margin: 0 1; }
|
|
526
|
+
.narrow .stat { padding: 0 1; }
|
|
527
|
+
.narrow #controls { margin: 0 1; }
|
|
528
|
+
.narrow #scope { width: 29; }
|
|
529
|
+
.narrow #pages { margin: 0 1; }
|
|
530
|
+
.narrow Tab { padding: 0 1; }
|
|
531
|
+
.narrow #agent-bottom { height: 12; }
|
|
532
|
+
.narrow .split { layout: vertical; }
|
|
533
|
+
.narrow .list { width: 100%; height: 1fr; margin-right: 0; margin-bottom: 1; }
|
|
534
|
+
.narrow .detail, .narrow #trace-detail { width: 100%; height: 1fr; }
|
|
535
|
+
.compact #masthead { height: 1; }
|
|
536
|
+
.compact #summary { margin-bottom: 0; }
|
|
537
|
+
.compact Tabs { height: 2; padding-top: 0; }
|
|
538
|
+
.compact #agent-bottom, .compact .detail, .compact #trace-detail, .compact #host-detail { display: none; }
|
|
539
|
+
.compact .list { width: 100%; height: 1fr; margin: 0; }
|
|
540
|
+
.expanded .results { display: none; }
|
|
541
|
+
.expanded #agent-bottom { display: block; height: 1fr; margin: 0; }
|
|
542
|
+
.expanded .detail, .expanded #trace-detail, .expanded #host-detail { display: block; width: 100%; height: 1fr; }
|
|
543
|
+
.expanded #agent-info { width: 100%; margin: 0; }
|
|
544
|
+
.expanded #agent-actions { display: none; }
|
|
545
|
+
.expanded #span-info { height: 2fr; }
|
|
546
|
+
.inspecting #summary, .inspecting #controls { display: none; }
|
|
547
|
+
'''
|
|
548
|
+
BINDINGS=[Binding('/','search','Search'),Binding('e','expand','Expand'),Binding('t','open_trace','Trace'),
|
|
549
|
+
Binding('c','copy','Copy'), Binding('alt+left','history_back','Back',show=False),
|
|
550
|
+
Binding('[','history("newer")','Newer',show=False),Binding(']','history("older")','Older',show=False),
|
|
551
|
+
Binding('l','history("latest")','Latest',show=False),
|
|
552
|
+
Binding('p','pause','Pause'),Binding('a','all','Reset',show=False),Binding('v','reviews','Reviews',show=False),
|
|
553
|
+
Binding('b','browser','Web'),Binding('q','quit','Quit'),Binding('escape','back','Back',show=False),
|
|
554
|
+
Binding('s','focus_agent','Focus agent',show=False),Binding('1','page("agents")','Agents',show=False),
|
|
555
|
+
Binding('2','page("activity")','Activity',show=False),Binding('3','page("mcp")','MCP',show=False),
|
|
556
|
+
Binding('4','page("traces")','Traces',show=False),Binding('5','page("host")','Host',show=False),Binding('6','page("output")','Output',show=False)]
|
|
557
|
+
|
|
558
|
+
def __init__(self,feed,agent=None,owns_receiver=False):
|
|
559
|
+
super().__init__()
|
|
560
|
+
self.feed,self.agent,self.owns_receiver=feed,agent,owns_receiver
|
|
561
|
+
self.data=feed.snapshot
|
|
562
|
+
self.last_update=0
|
|
563
|
+
self.table_data={}
|
|
564
|
+
self.table_signatures={}
|
|
565
|
+
self.inspector_signatures={}
|
|
566
|
+
self.selected_source=None
|
|
567
|
+
self.selected_activity=None
|
|
568
|
+
self.selected_mcp=None
|
|
569
|
+
self.scope_options=None
|
|
570
|
+
self.scope_value='all'
|
|
571
|
+
self.span_signature=None
|
|
572
|
+
self.search_timers={}
|
|
573
|
+
self.refresh_timer=None
|
|
574
|
+
self.search_queries={page:'' for page in ('agents','activity','mcp','traces','host')}
|
|
575
|
+
self.errors_only=set()
|
|
576
|
+
self.navigation_history=[]
|
|
577
|
+
|
|
578
|
+
def results(self,page,table,placeholder,classes=''):
|
|
579
|
+
"""Keep a view's filter, result count, and rows in one panel."""
|
|
580
|
+
controls=[SearchInput(placeholder=placeholder,id='search-'+page,classes='view-search')]
|
|
581
|
+
if page in {'activity','mcp'}:
|
|
582
|
+
controls.append(Button('Errors',id='errors-'+page,classes='error-filter',tooltip='Show only failed calls in the current results'))
|
|
583
|
+
controls.append(Button('Clear',id='clear-'+page,classes='clear-search',disabled=True))
|
|
584
|
+
footer=[]
|
|
585
|
+
if page in {'activity','mcp','traces'}:
|
|
586
|
+
footer=[Horizontal(Static('Latest',id='history-label-'+page),
|
|
587
|
+
Button('Newer',id='newer-'+page,compact=True,disabled=True),
|
|
588
|
+
Button('Older',id='older-'+page,compact=True,disabled=True),
|
|
589
|
+
Button('Latest',id='latest-'+page,compact=True,disabled=True),classes='history-controls')]
|
|
590
|
+
return Vertical(
|
|
591
|
+
Horizontal(*controls,classes='searchbar'),
|
|
592
|
+
Static('Loading…',id='mcp-hint' if page=='mcp' else 'search-status-'+page,classes='search-status',markup=False),
|
|
593
|
+
ResultsTable(id=table,cursor_type='row',zebra_stripes=True),
|
|
594
|
+
*footer,
|
|
595
|
+
id='results-'+page,classes='box results '+classes)
|
|
596
|
+
|
|
597
|
+
def compose(self)->ComposeResult:
|
|
598
|
+
with Horizontal(id='masthead'):
|
|
599
|
+
yield Static('◈ OBSERVATORY',id='brand')
|
|
600
|
+
yield Static('○ Connecting',id='connection',markup=False)
|
|
601
|
+
with Horizontal(id='summary'):
|
|
602
|
+
for name in ('sessions','processes','mcp-count','usage'):
|
|
603
|
+
yield Static('',id='stat-'+name,classes='stat',markup=False)
|
|
604
|
+
with Horizontal(id='controls'):
|
|
605
|
+
yield Button('← Back',id='history-back',disabled=True,tooltip='Return to the event you opened · Alt+Left')
|
|
606
|
+
yield Static('Scope',id='scope-label')
|
|
607
|
+
yield Select([('All agent sessions','all')],value='all',allow_blank=False,id='scope')
|
|
608
|
+
yield Static('',id='controls-spacer')
|
|
609
|
+
yield Checkbox('Reviews',id='reviews')
|
|
610
|
+
yield Button('Pause',id='pause')
|
|
611
|
+
with TabbedContent(initial='agents',id='pages'):
|
|
612
|
+
with TabPane('1 Agents',id='agents'):
|
|
613
|
+
yield self.results('agents','agent-table','Filter agents by task, project, model, or session…')
|
|
614
|
+
with Horizontal(id='agent-bottom'):
|
|
615
|
+
with Vertical(id='agent-info'):
|
|
616
|
+
yield SelectableRichLog(id='agent-detail',classes='box',min_width=1,wrap=True,markup=False,auto_scroll=False)
|
|
617
|
+
yield Button(Text('Session activity · s'),id='agent-open')
|
|
618
|
+
yield ResultsTable(id='agent-actions',classes='box',cursor_type='row',zebra_stripes=True)
|
|
619
|
+
with TabPane('2 Activity',id='activity'):
|
|
620
|
+
with Horizontal(classes='split'):
|
|
621
|
+
yield self.results('activity','activity-table','Filter activity by command, message, tool, or path…','list')
|
|
622
|
+
with Vertical(id='activity-inspector',classes='detail box'):
|
|
623
|
+
yield SelectableRichLog(id='activity-detail',classes='detail-log',min_width=1,wrap=True,markup=False,auto_scroll=False)
|
|
624
|
+
with Horizontal(classes='inspector-actions'):
|
|
625
|
+
yield Button(Text('Open trace · t'),id='activity-trace')
|
|
626
|
+
yield Button(Text('Copy event · c'),id='activity-copy')
|
|
627
|
+
yield Button(Text('Expand · e'),id='expand-activity',classes='expand-button')
|
|
628
|
+
with TabPane('3 MCP Calls',id='mcp'):
|
|
629
|
+
with Horizontal(classes='split'):
|
|
630
|
+
yield self.results('mcp','mcp-table','Filter MCP calls by server, tool, arguments, or result…','list')
|
|
631
|
+
with Vertical(id='mcp-inspector',classes='detail box'):
|
|
632
|
+
yield SelectableRichLog(id='mcp-detail',classes='detail-log',min_width=1,wrap=True,markup=False,auto_scroll=False)
|
|
633
|
+
with Horizontal(classes='inspector-actions'):
|
|
634
|
+
yield Button(Text('Open trace · t'),id='mcp-trace')
|
|
635
|
+
yield Button(Text('Copy call · c'),id='mcp-copy')
|
|
636
|
+
yield Button(Text('Expand · e'),id='expand-mcp',classes='expand-button')
|
|
637
|
+
with TabPane('4 Traces',id='traces'):
|
|
638
|
+
with Horizontal(classes='split'):
|
|
639
|
+
yield self.results('traces','trace-table','Filter traces by task, model, service, or ID…','list')
|
|
640
|
+
with Vertical(id='trace-detail',classes='box'):
|
|
641
|
+
yield Tree('Select a trace',id='span-tree')
|
|
642
|
+
yield SelectableRichLog(id='span-info',min_width=1,wrap=True,markup=False,auto_scroll=False)
|
|
643
|
+
with TabPane('5 Host',id='host'):
|
|
644
|
+
yield self.results('host','host-table','Filter processes by name, PID, project, or state…')
|
|
645
|
+
yield SelectableRichLog(id='host-detail',classes='box',min_width=1,wrap=True,markup=False,auto_scroll=False)
|
|
646
|
+
if self.agent:
|
|
647
|
+
with TabPane('6 Output',id='output'):
|
|
648
|
+
yield Static('stdout / stderr from the agent launched by this cockpit.',id='output-state',classes='hint')
|
|
649
|
+
yield SelectableRichLog(id='agent-output',classes='box',min_width=1,wrap=True,markup=False,auto_scroll=True)
|
|
650
|
+
yield Static('Connecting to receiver…',id='health',markup=False)
|
|
651
|
+
yield Footer()
|
|
652
|
+
|
|
653
|
+
def get_default_screen(self):
|
|
654
|
+
return ObservatoryScreen()
|
|
655
|
+
|
|
656
|
+
def on_mount(self):
|
|
657
|
+
columns={
|
|
658
|
+
'agent-table':[('Agent',15,1),('Project',10,1),('Status',8,0),('Current / last task',20,5),('Seen',7,0)],
|
|
659
|
+
'agent-actions':[('Time',8,0),('Type',7,0),('Recent actions',15,1)],
|
|
660
|
+
'activity-table':[('Time',8,0),('Type',7,0),('Result',9,0),('Action',20,1)],
|
|
661
|
+
'mcp-table':[('Time',8,0),('Agent',11,1),('Server',9,1),('Tool',14,3),('Result',7,0),('Latency',7,0)],
|
|
662
|
+
'trace-table':[('Result',11,0),('Task / run',20,1),('Spans',5,0),('Tokens in / out',15,0)],
|
|
663
|
+
'host-table':[('Process',10,1),('PID',7,0),('Project',12,2),('State',8,1),('RAM',9,0),('CPU total',9,0)]}
|
|
664
|
+
for name,cols in columns.items():
|
|
665
|
+
self.query_one('#'+name,ResultsTable).configure(cols)
|
|
666
|
+
for name,title in [('results-agents','Agent sessions'),('agent-detail','Session overview'),('agent-actions','Recent work'),('results-activity','Activity stream'),('activity-inspector','Event detail'),('results-mcp','MCP calls'),('mcp-inspector','Call detail'),('results-traces','Traces'),('trace-detail','Execution tree'),('results-host','Agent processes'),('host-detail','Collection status')]:
|
|
667
|
+
self.query_one('#'+name).border_title=title
|
|
668
|
+
for page in self.search_queries:
|
|
669
|
+
self.query_one('#results-'+page).border_subtitle='↑↓ select · Enter inspect'
|
|
670
|
+
self.query_one('#search-'+page).tooltip='Search this view · / to focus · Esc to return'
|
|
671
|
+
self.query_one('#reviews').tooltip='Include internal approval review sessions'
|
|
672
|
+
self.query_one('#pause').tooltip='Pause display updates · collection continues'
|
|
673
|
+
for log in self.query(SelectableRichLog):
|
|
674
|
+
log.tooltip='Drag to select text · Ctrl+C copies selection · c copies details'
|
|
675
|
+
self.query_one('#host-detail').styles.height=8
|
|
676
|
+
self.update_layout()
|
|
677
|
+
if not self.feed.thread.is_alive():self.feed.thread.start()
|
|
678
|
+
self.refresh_timer=self.set_interval(.3,self.refresh_data)
|
|
679
|
+
self.query_one('#agent-table',DataTable).focus()
|
|
680
|
+
|
|
681
|
+
def on_unmount(self):
|
|
682
|
+
if self.refresh_timer:self.refresh_timer.stop()
|
|
683
|
+
for timer in self.search_timers.values():timer.stop()
|
|
684
|
+
|
|
685
|
+
def on_resize(self,event):
|
|
686
|
+
self.update_layout(event.size)
|
|
687
|
+
self.call_after_refresh(self.reflow_inspectors)
|
|
688
|
+
|
|
689
|
+
def reflow_inspectors(self):
|
|
690
|
+
self.screen.clear_selection()
|
|
691
|
+
positions={log.id:log.scroll_offset for log in self.query(SelectableRichLog)}
|
|
692
|
+
self.inspector_signatures.clear()
|
|
693
|
+
self.last_update=-1
|
|
694
|
+
self.refresh_data()
|
|
695
|
+
trees=self.query('#span-tree')
|
|
696
|
+
if trees and trees.first().cursor_node:
|
|
697
|
+
self.show_span(trees.first().cursor_node.data)
|
|
698
|
+
def restore():
|
|
699
|
+
for name,position in positions.items():
|
|
700
|
+
self.query_one('#'+name).scroll_to(x=position.x,y=position.y,animate=False)
|
|
701
|
+
self.call_after_refresh(restore)
|
|
702
|
+
|
|
703
|
+
def update_layout(self,size=None):
|
|
704
|
+
size=size or self.size
|
|
705
|
+
self.screen.set_class(size.width<140,'narrow')
|
|
706
|
+
self.screen.set_class(size.height<36,'compact')
|
|
707
|
+
|
|
708
|
+
def stat(self,name,value,label,detail):
|
|
709
|
+
text=Text(label.upper()+'\n',style='#8b9bb0',no_wrap=True,overflow='ellipsis')
|
|
710
|
+
text.append(str(value),style='bold #e6edf3')
|
|
711
|
+
text.append('\n'+detail,style='#8b9bb0')
|
|
712
|
+
self.query_one('#stat-'+name,Static).update(text)
|
|
713
|
+
|
|
714
|
+
def fill(self,name,rows,preserve=None):
|
|
715
|
+
"""Preserve row identity and scroll position while live values change."""
|
|
716
|
+
signature=tuple((key,tuple(str(value) for value in values)) for key,values,record in rows)
|
|
717
|
+
self.table_data[name]={key:record for key,values,record in rows}
|
|
718
|
+
if self.table_signatures.get(name)==signature:return
|
|
719
|
+
self.table_signatures[name]=signature
|
|
720
|
+
table=self.query_one('#'+name,DataTable)
|
|
721
|
+
selected=preserve
|
|
722
|
+
if table.row_count:
|
|
723
|
+
try:
|
|
724
|
+
if not selected:selected=table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
|
|
725
|
+
except Exception:pass
|
|
726
|
+
scroll=table.scroll_offset
|
|
727
|
+
old_index=table.cursor_row
|
|
728
|
+
with table.prevent(DataTable.RowHighlighted):
|
|
729
|
+
if [key.value for key in table.rows]==[key for key,_,_ in rows]:
|
|
730
|
+
for key,values,_ in rows:
|
|
731
|
+
for column,value in zip(table.columns,values):table.update_cell(key,column,value)
|
|
732
|
+
else:
|
|
733
|
+
table.clear()
|
|
734
|
+
for key,values,record in rows:table.add_row(*values,key=key)
|
|
735
|
+
if rows:
|
|
736
|
+
index=next((index for index,(key,_,__) in enumerate(rows) if key==selected),0)
|
|
737
|
+
table.move_cursor(row=index,scroll=False)
|
|
738
|
+
table.scroll_to(x=scroll.x,y=max(0,scroll.y+index-old_index),animate=False)
|
|
739
|
+
|
|
740
|
+
def search_status(self,page,count,noun,plural=None):
|
|
741
|
+
query=self.search_queries[page]
|
|
742
|
+
waiting=page in {'activity','mcp','traces'} and self.data.queries.get(page,'')!=query
|
|
743
|
+
text='Filtering…' if waiting else quantity(count,('matching '+noun) if query else noun,
|
|
744
|
+
('matching '+plural) if query and plural else plural)
|
|
745
|
+
if not waiting and query and not count:text+=' · Clear to remove this filter'
|
|
746
|
+
if page in self.errors_only:text+=' · errors only'
|
|
747
|
+
self.query_one('#search-status-'+page,Static).update(text)
|
|
748
|
+
|
|
749
|
+
def visible_results(self,page,items):
|
|
750
|
+
if self.data.queries.get(page,'')!=self.search_queries[page]:return []
|
|
751
|
+
if page in self.errors_only:
|
|
752
|
+
return [item for item in items if str(item.get('status') or item.get('result') or '').lower() in {'error','failed','denied','blocked','policy_denied','incomplete'}]
|
|
753
|
+
return items
|
|
754
|
+
|
|
755
|
+
def refresh_data(self):
|
|
756
|
+
# Timer messages can already be queued when child widgets unmount.
|
|
757
|
+
if not self.query('#health'):return
|
|
758
|
+
data=self.feed.snapshot
|
|
759
|
+
connection='Ⅱ Paused' if self.feed.paused else '○ Reconnecting' if data.error else '● Connected'
|
|
760
|
+
endpoint=self.feed.api.endpoint.removeprefix('http://').removeprefix('https://')
|
|
761
|
+
self.query_one('#connection',Static).update(Text(connection+' '+endpoint,
|
|
762
|
+
style='#f2c779' if self.feed.paused else '#ff8b92' if data.error else '#6ee7c2'))
|
|
763
|
+
health=Text(data.error,style='#ff8b92') if data.error else Text(
|
|
764
|
+
f"{'Display paused' if self.feed.paused else 'Updated '+age(int(data.updated*1e9))} · {self.scope_label()} · {'Reviews included' if self.feed.include_reviews else 'Reviews hidden'}",
|
|
765
|
+
style='#8b9bb0')
|
|
766
|
+
self.query_one('#health',Static).update(health)
|
|
767
|
+
if self.screen.selections:
|
|
768
|
+
self.query_one('#health',Static).update('Text selected · Ctrl+C to copy · click to clear selection and resume display updates')
|
|
769
|
+
return
|
|
770
|
+
if data.updated==self.last_update:return
|
|
771
|
+
self.last_update=data.updated;self.data=data
|
|
772
|
+
if self.agent:
|
|
773
|
+
lines=list(self.agent.lines)
|
|
774
|
+
if getattr(self,'output_lines',None)!=lines:
|
|
775
|
+
self.output_lines=lines
|
|
776
|
+
output=self.query_one('#agent-output',RichLog);output.clear();output.write(Text('\n'.join(lines)))
|
|
777
|
+
code=self.agent.process.poll()
|
|
778
|
+
self.query_one('#output-state',Static).update('Agent process running · stdout / stderr' if code is None else f'Agent process exited with code {code} · captured stdout / stderr')
|
|
779
|
+
agents=sources(data,self.feed.include_reviews)
|
|
780
|
+
work=sources(data,False)
|
|
781
|
+
recent=sum(item['recent'] for item in work)
|
|
782
|
+
reviews=sum(entry.get('service')=='codex-review' for entry in data.local_status.get('sessions',[]))
|
|
783
|
+
processes=(data.local_status.get('runtime') or {}).get('processes',[])
|
|
784
|
+
live=sum(not str(item.get('state','')).startswith(('Z','X')) for item in processes)
|
|
785
|
+
self.stat('sessions',len(work),'Agent sessions',f'{recent} recent · {len(work)-recent} idle / offline')
|
|
786
|
+
self.stat('processes',live,'Host processes','PIDs on this host')
|
|
787
|
+
servers={item.get('server') for item in data.mcp if item.get('server') and item.get('server')!='Not reported'}
|
|
788
|
+
operations={(item.get('server'),item.get('tool')) for item in data.mcp if item.get('tool') and item.get('tool')!='Not reported'}
|
|
789
|
+
mcp_errors=sum(str(item.get('status','')).upper() in {'ERROR','FAILED'} for item in data.mcp)
|
|
790
|
+
self.stat('mcp-count',quantity(len(data.mcp),'call')+('' if self.size.width<110 else ' · '+quantity(len(servers),'server')),'MCP in scope',
|
|
791
|
+
f'{quantity(len(servers),"server")} · {quantity(mcp_errors,"error")}' if self.size.width<110 else f'{quantity(len(operations),"tool")} · {quantity(mcp_errors,"error")}')
|
|
792
|
+
self.stat('usage',number(data.totals.get('input_tokens'))+' / '+number(data.totals.get('output_tokens')),'Tokens in / out','Includes cached input')
|
|
793
|
+
self.query_one('#stat-sessions').tooltip=f'{reviews} approval reviews · Recent means a session record changed within 35 seconds'
|
|
794
|
+
self.query_one('#stat-usage').tooltip='Recorded token totals; repeated and cached input is included. This is not a billable cost estimate.'
|
|
795
|
+
options=[('All agent sessions','all')]+[(f"{item['runtime']} · {item.get('project') or 'SDK'} · {(item.get('session_id') or item['id'])[-6:]}",item['id']) for item in agents]
|
|
796
|
+
if options!=self.scope_options:
|
|
797
|
+
picker=self.query_one('#scope',Select);old=self.scope_value
|
|
798
|
+
picker.set_options(options);picker.value=old if any(value==old for _,value in options) else 'all';self.scope_options=options
|
|
799
|
+
self.source_map={item['id']:item for item in agents}
|
|
800
|
+
agent_query=self.search_queries['agents'].casefold()
|
|
801
|
+
visible_agents=[item for item in agents if (self.scope_value=='all' or item['id']==self.scope_value) and
|
|
802
|
+
(not agent_query or agent_query in ' '.join(str(item.get(key) or '') for key in ('runtime','service','project','task','model','session_id','id','state')).casefold())]
|
|
803
|
+
self.fill('agent-table',[(item['id'],[cell(item['runtime']+' · '+(item.get('session_id') or item['id'])[-6:]),cell(item.get('project') or '—','#a4b7cb'),cell(('● ' if item['recent'] else '○ ')+item['state'],'#6ee7c2' if item['recent'] else '#8b9bb0'),cell(item.get('task') or 'Task not reported'),cell(age(item['stamp']),'#8b9bb0')],item) for item in visible_agents])
|
|
804
|
+
self.search_status('agents',len(visible_agents),'session')
|
|
805
|
+
if self.selected_source not in {item['id'] for item in visible_agents}:self.selected_source=visible_agents[0]['id'] if visible_agents else None
|
|
806
|
+
self.show_agent()
|
|
807
|
+
activity_items=self.visible_results('activity',data.activity_results if data.activity_results is not None else data.activity)
|
|
808
|
+
activity_items=self.hold_selected('activity',activity_items,self.selected_activity,'source_id')
|
|
809
|
+
activity=[(item.get('id') or item['source_id'],[cell(table_when(item['time_unix_nano'])),cell(item['domain'].upper(),'#92b9eb'),status(item.get('result')),cell(headline(item))],item) for item in activity_items]
|
|
810
|
+
self.fill('activity-table',activity)
|
|
811
|
+
self.search_status('activity',len(activity),'event')
|
|
812
|
+
if self.selected_activity not in self.table_data.get('activity-table',{}):self.selected_activity=activity[0][0] if activity else None
|
|
813
|
+
self.show_activity()
|
|
814
|
+
mcp_items=self.visible_results('mcp',data.mcp_results if data.mcp_results is not None else data.mcp)
|
|
815
|
+
mcp_items=self.hold_selected('mcp',mcp_items,self.selected_mcp,'id')
|
|
816
|
+
self.fill('mcp-table',[(item['id'],[cell(table_when(item['time_unix_nano']),'#8b9bb0'),cell((item.get('service') or 'Unknown')+' · '+str(item.get('session_id') or '—')[-6:]),cell(item['server'],'#a4b7cb'),cell(item['tool']),status(item['status']),cell('—' if item['duration_ms'] is None else timing(item['duration_ms']))],item) for item in mcp_items])
|
|
817
|
+
if self.selected_mcp not in self.table_data.get('mcp-table',{}):self.selected_mcp=mcp_items[0]['id'] if mcp_items else None
|
|
818
|
+
self.show_mcp()
|
|
819
|
+
if not data.mcp_available:
|
|
820
|
+
mcp_hint='This receiver version does not expose MCP operations. Update and restart the receiver.'
|
|
821
|
+
elif data.queries.get('mcp','')!=self.search_queries['mcp']:
|
|
822
|
+
mcp_hint='Filtering MCP operations…'
|
|
823
|
+
elif mcp_items:
|
|
824
|
+
server_count=len({item.get('server') for item in mcp_items})
|
|
825
|
+
error_count=sum(str(item.get('status','')).upper() in {'ERROR','FAILED'} for item in mcp_items)
|
|
826
|
+
mcp_hint=f"{quantity(len(mcp_items),'matching operation' if self.search_queries['mcp'] else 'operation')} · {quantity(server_count,'server')} · {quantity(error_count,'error')}"
|
|
827
|
+
elif self.search_queries['mcp']:
|
|
828
|
+
mcp_hint='No matching MCP operations · Clear to show all calls in this scope'
|
|
829
|
+
else:
|
|
830
|
+
mcp_hint='No MCP operations in this scope · General tool activity is available in Activity'
|
|
831
|
+
if 'mcp' in self.errors_only:mcp_hint=f"{quantity(len(mcp_items),'failed call')} · errors only · current results"
|
|
832
|
+
self.query_one('#mcp-hint',Static).update(mcp_hint)
|
|
833
|
+
traces=self.visible_results('traces',data.traces[:])
|
|
834
|
+
detail=data.detail
|
|
835
|
+
if self.feed.pinned_trace and detail.get('summary') and not any(item['trace_id']==detail['trace_id'] for item in traces):traces.insert(0,detail['summary'])
|
|
836
|
+
self.fill('trace-table',[(item['trace_id'],[cell('Active · errors','yellow') if item.get('active') and item['status']=='ERROR' else cell('Error','red') if item['status']=='ERROR' else status(item['status']),cell(trace_name(item)),cell(item['span_count']),cell(number(item['input_tokens'])+' / '+number(item['output_tokens']))],item) for item in traces],preserve=self.feed.selected_trace)
|
|
837
|
+
self.search_status('traces',len(traces),'trace')
|
|
838
|
+
for page in ('activity','mcp','traces'):
|
|
839
|
+
paging=data.pagination.get(page,{})
|
|
840
|
+
page_number=paging.get('page',1)
|
|
841
|
+
self.query_one('#history-label-'+page,Static).update('Latest' if page_number==1 else f'History · page {page_number}')
|
|
842
|
+
self.query_one('#older-'+page,Button).disabled=not paging.get('next_cursor')
|
|
843
|
+
self.query_one('#newer-'+page,Button).disabled=page_number==1
|
|
844
|
+
self.query_one('#latest-'+page,Button).disabled=page_number==1
|
|
845
|
+
self.query_one('#older-'+page).tooltip='Load older saved records · ]' if paging.get('supported') else 'Restart the updated receiver to enable history paging'
|
|
846
|
+
self.show_spans()
|
|
847
|
+
host_query=self.search_queries['host'].casefold()
|
|
848
|
+
visible_processes=[item for item in processes if not host_query or host_query in ' '.join(str(item.get(key) or '') for key in ('name','pid','parent_pid','project','state')).casefold()]
|
|
849
|
+
self.fill('host-table',[(str(item['pid']),[cell(item['name']),cell(item['pid']),cell(item.get('project') or '—'),cell(item['state']),cell(f"{item['memory_rss_kib']/1024:.0f} MiB"),cell(f"{item['cpu_seconds']:.1f} s")],item) for item in visible_processes])
|
|
850
|
+
self.search_status('host',len(visible_processes),'process','processes')
|
|
851
|
+
runtime=data.local_status.get('runtime') or {}
|
|
852
|
+
self.inspect('host-detail','Host & collection status',[
|
|
853
|
+
('Host',runtime.get('host','Not reported')),('Platform',str(runtime.get('os',''))+' / '+str(runtime.get('architecture',''))),
|
|
854
|
+
('Load average',', '.join(str(value) for value in runtime.get('load_average',[])) or 'Unavailable'),
|
|
855
|
+
('Sources',f"{data.local_status.get('sources',0)} recent files · lookback {data.local_status.get('lookback_hours',24)}h · read errors {data.local_status.get('read_errors',0)}"),
|
|
856
|
+
('Coverage','Source logs can be partial. Costs and model-call latency are unavailable unless reported. CPU is cumulative process time.')],[])
|
|
857
|
+
|
|
858
|
+
def scope_label(self):
|
|
859
|
+
item=getattr(self,'source_map',{}).get(self.scope_value)
|
|
860
|
+
return f"{item['runtime']} / {item.get('project') or 'SDK'}" if item else 'all agent sessions'
|
|
861
|
+
|
|
862
|
+
def hold_selected(self,page,items,selected,key):
|
|
863
|
+
"""Keep the event being read when new arrivals push it off the live page."""
|
|
864
|
+
current=self.table_data.get(page+'-table',{}).get(selected)
|
|
865
|
+
if current and selected not in {item.get('id') or item[key] for item in items}:
|
|
866
|
+
return [*items,current]
|
|
867
|
+
return items
|
|
868
|
+
|
|
869
|
+
def inspect(self,name,title,fields,sections):
|
|
870
|
+
signature=(title,str(fields),str(sections))
|
|
871
|
+
if self.inspector_signatures.get(name)==signature:return
|
|
872
|
+
previous=self.inspector_signatures.get(name)
|
|
873
|
+
self.inspector_signatures[name]=signature
|
|
874
|
+
log=self.query_one('#'+name,RichLog)
|
|
875
|
+
scroll=log.scroll_offset
|
|
876
|
+
log.clear();log.write(Text(title,style='bold #e6edf3'))
|
|
877
|
+
table=Table.grid(padding=(0,2));table.add_column(style='#8b9bb0',no_wrap=True);table.add_column()
|
|
878
|
+
for label,value in fields:table.add_row(Text(label),Text(str('Not reported' if value is None or value=='' else value)))
|
|
879
|
+
log.write(table)
|
|
880
|
+
for label,value in sections:self.write_evidence(log,label,value)
|
|
881
|
+
if previous and previous[0]==title and previous[2]==signature[2]:
|
|
882
|
+
log.scroll_to(y=scroll.y,animate=False)
|
|
883
|
+
|
|
884
|
+
def write_evidence(self,log,label,value,depth=0):
|
|
885
|
+
if value is None or value=='':return
|
|
886
|
+
value=decode_nested(value)
|
|
887
|
+
if isinstance(value,str) and label.casefold() in {'recorded content','text','output','response'}:
|
|
888
|
+
segments=embedded_json(value)
|
|
889
|
+
if any(kind=='json' for kind,_ in segments):
|
|
890
|
+
log.write(Text('\n'+label.title(),style='bold #92b9eb'))
|
|
891
|
+
for kind,content in segments:
|
|
892
|
+
if kind=='text':
|
|
893
|
+
if content.strip():log.write(Text(content.strip('\n')))
|
|
894
|
+
else:
|
|
895
|
+
write_json(log,content)
|
|
896
|
+
return
|
|
897
|
+
# Native log adapters may wrap each piece of the same record in a
|
|
898
|
+
# one-key `recorded_content` envelope. Unwrap that without consuming
|
|
899
|
+
# the JSON nesting budget or repeating the label at every level.
|
|
900
|
+
if isinstance(value,dict) and len(value)==1:
|
|
901
|
+
key,child=next(iter(value.items()))
|
|
902
|
+
if key.lower() in {'recorded_content','content','payload'}:
|
|
903
|
+
self.write_evidence(log,label,child,depth);return
|
|
904
|
+
if key.lower()=='wrapper':
|
|
905
|
+
self.write_evidence(log,label,child,depth+1);return
|
|
906
|
+
if depth==0 and isinstance(value,(dict,list)) and value:
|
|
907
|
+
log.write(Text('\n'+label.upper(),style='bold #6ee7c2'))
|
|
908
|
+
if label.lower()=='arguments':
|
|
909
|
+
write_json(log,value)
|
|
910
|
+
return
|
|
911
|
+
if isinstance(value,(dict,list)) and not value:
|
|
912
|
+
log.write(Text('\n'+label.title(),style='bold #92b9eb'))
|
|
913
|
+
log.write(Text('No entries' if isinstance(value,list) else 'Empty object'))
|
|
914
|
+
return
|
|
915
|
+
if isinstance(value,dict) and value.get('type') in {'text','input_text','output_text'} and 'text' in value:
|
|
916
|
+
self.write_evidence(log,label,value['text'],depth+1);return
|
|
917
|
+
# Any structured value nested in captured evidence is easier to scan
|
|
918
|
+
# as JSON than as a sequence of flattened label/value rows. This also
|
|
919
|
+
# handles JSON strings stored under fields such as `output` or `text`.
|
|
920
|
+
if depth and isinstance(value,(dict,list)):
|
|
921
|
+
write_json(log,value)
|
|
922
|
+
return
|
|
923
|
+
if depth<64 and isinstance(value,dict):
|
|
924
|
+
for key,child in value.items():
|
|
925
|
+
self.write_evidence(log,str(key).replace('_',' '),child,depth+1)
|
|
926
|
+
return
|
|
927
|
+
if depth<64 and isinstance(value,list):
|
|
928
|
+
for child in value:self.write_evidence(log,label,child,depth+1)
|
|
929
|
+
return
|
|
930
|
+
if isinstance(value,(dict,list)):
|
|
931
|
+
log.write(Text('\n'+label.title(),style='bold #92b9eb'))
|
|
932
|
+
write_json(log,value)
|
|
933
|
+
return
|
|
934
|
+
log.write(Text('\n'+label.title(),style='bold #92b9eb'))
|
|
935
|
+
if label.lower()=='command':
|
|
936
|
+
command=shell_command(value)
|
|
937
|
+
script=heredoc_script(command)
|
|
938
|
+
if script:
|
|
939
|
+
language,title,body=script
|
|
940
|
+
log.write(Text(title,style='bold #92b9eb'))
|
|
941
|
+
log.write(Syntax(body or '# Empty script',language,word_wrap=True,background_color='#101720'))
|
|
942
|
+
return
|
|
943
|
+
log.write(Syntax(command,'bash',word_wrap=True,background_color='#101720'))
|
|
944
|
+
elif label.lower() in {'diff','patch','unified diff','code'}:
|
|
945
|
+
lexer='diff' if 'diff' in label.lower() or label.lower()=='patch' else 'python' if label.lower()=='code' else 'bash'
|
|
946
|
+
log.write(Syntax(str(value),lexer,word_wrap=True,background_color='#101720'))
|
|
947
|
+
else:log.write(Text(str(value)))
|
|
948
|
+
|
|
949
|
+
def show_agent(self):
|
|
950
|
+
item=getattr(self,'source_map',{}).get(self.selected_source)
|
|
951
|
+
if not item:
|
|
952
|
+
self.query_one('#agent-open',Button).disabled=True
|
|
953
|
+
self.fill('agent-actions',[])
|
|
954
|
+
if self.search_queries['agents']:
|
|
955
|
+
self.inspect('agent-detail','No matching agent sessions',[],[('Search','Clear the filter in Agent sessions to show all sessions in this scope.')])
|
|
956
|
+
else:
|
|
957
|
+
self.inspect('agent-detail','No agent sessions found',[],[('Next step','Local Codex and Claude Code sessions are discovered automatically. Python SDK agents can use the capture launcher.')])
|
|
958
|
+
return
|
|
959
|
+
self.query_one('#agent-open',Button).disabled=False
|
|
960
|
+
summary=next((entry for entry in self.data.sessions if item.get('session_id') and entry.get('session_id')==item.get('session_id')),{})
|
|
961
|
+
latest=item.get('last_activity') or {}
|
|
962
|
+
self.inspect('agent-detail',clean(item.get('task') or 'Task not reported'),[
|
|
963
|
+
('Agent',f"{item['runtime']} · {item.get('project') or 'SDK'}"),
|
|
964
|
+
('Latest',headline(latest) if latest else 'Not reported'),
|
|
965
|
+
('Model',item.get('model') or 'Not reported'),('Seen',f"{item['state']} · {age(item.get('stamp'))}"),
|
|
966
|
+
('Work',f"{summary.get('trace_count',0)} traces · {item.get('spans',0)} spans · {number(summary.get('input_tokens'))} in / {number(summary.get('output_tokens'))} out"),
|
|
967
|
+
('Cost','Not reported' if not summary or summary.get('unknown_cost_runs') else f"${summary.get('known_cost_usd',0):.4f} known")],
|
|
968
|
+
[('Session ID',item.get('session_id') or item['id'])])
|
|
969
|
+
actions=[event for event in self.data.activity if event.get('session_id')==item.get('session_id')] if item.get('session_id') else [event for event in self.data.activity if event.get('service')==item.get('service')]
|
|
970
|
+
meaningful=[event for event in actions if event.get('domain') in {'shell','file','mcp','browser','model','message'}]
|
|
971
|
+
actions=sorted(meaningful or actions,key=lambda event:event.get('time_unix_nano',0),reverse=True)
|
|
972
|
+
self.fill('agent-actions',[(event.get('id') or event['source_id'],[cell(table_when(event['time_unix_nano'])),cell(event['domain'].upper()),cell(headline(event))],event) for event in actions[:30]])
|
|
973
|
+
|
|
974
|
+
def show_activity(self):
|
|
975
|
+
item=self.table_data.get('activity-table',{}).get(self.selected_activity)
|
|
976
|
+
self.query_one('#activity-trace',Button).disabled=not bool(item and item.get('trace_id'))
|
|
977
|
+
if not item:
|
|
978
|
+
if 'activity' in self.errors_only:
|
|
979
|
+
self.inspect('activity-detail','No failed events',[],[('Errors filter','No failures in the current results. Turn off Errors to see all activity.')]);return
|
|
980
|
+
self.inspect('activity-detail','No activity in this scope',[],[('Scope','Choose another agent session or clear the search to view recent activity.')]);return
|
|
981
|
+
details=decode(item.get('detail') or '')
|
|
982
|
+
sections=list(details.items()) if isinstance(details,dict) else [('Recorded content',details)]
|
|
983
|
+
self.inspect('activity-detail',headline(item),[('Agent',item.get('service')),('Type',item.get('domain')),('Result',str(status(item.get('result')))),
|
|
984
|
+
('When',when(item['time_unix_nano'])),('Duration',timing(item.get('duration_ms')))],
|
|
985
|
+
sections+[('Source',item.get('source')),('Trace ID',item.get('trace_id')),('Span ID',item.get('span_id'))])
|
|
986
|
+
|
|
987
|
+
def show_mcp(self):
|
|
988
|
+
item=self.table_data.get('mcp-table',{}).get(self.selected_mcp)
|
|
989
|
+
self.query_one('#mcp-trace',Button).disabled=not bool(item and item.get('trace_id'))
|
|
990
|
+
if not item:
|
|
991
|
+
if not self.data.mcp_available:
|
|
992
|
+
title='MCP inventory unavailable'
|
|
993
|
+
note='The connected receiver is too old to provide this endpoint. Update and restart the receiver.'
|
|
994
|
+
elif self.data.queries.get('mcp','')!=self.search_queries['mcp']:
|
|
995
|
+
title='Filtering MCP operations'
|
|
996
|
+
note='Waiting for matching calls from the receiver.'
|
|
997
|
+
elif 'mcp' in self.errors_only:
|
|
998
|
+
title='No failed MCP calls'
|
|
999
|
+
note='No failures in the current results. Turn off Errors to see all calls.'
|
|
1000
|
+
elif self.search_queries['mcp']:
|
|
1001
|
+
title='No matching MCP operations'
|
|
1002
|
+
note='Clear Search or choose another agent session. Search also matches server, tool, arguments, and result.'
|
|
1003
|
+
else:
|
|
1004
|
+
title='No MCP operations in this scope'
|
|
1005
|
+
note='Calls identified as Model Context Protocol appear here. Other tool activity remains in Activity.'
|
|
1006
|
+
self.inspect('mcp-detail',title,[('Scope',self.scope_label())],[('How to proceed',note)])
|
|
1007
|
+
return
|
|
1008
|
+
self.inspect('mcp-detail',item['server']+' / '+item['tool'],[
|
|
1009
|
+
('Result',str(status(item.get('status')))+' · '+timing(item.get('duration_ms'))),
|
|
1010
|
+
('Agent',(item.get('service') or 'Not reported')+' · '+str(item.get('session_id') or 'Unknown session')[-12:]),
|
|
1011
|
+
('Method',item.get('method') or 'Not reported'),('Observed',when(item.get('time_unix_nano') or 0))],
|
|
1012
|
+
[('Arguments',item.get('arguments')),('Response',item.get('output')),
|
|
1013
|
+
('Evidence',item.get('source')),('Session ID',item.get('session_id')),
|
|
1014
|
+
('Trace ID',item.get('trace_id') or 'Not linked'),('Span ID',item.get('span_id') or 'Not linked')])
|
|
1015
|
+
|
|
1016
|
+
def show_spans(self):
|
|
1017
|
+
detail=self.data.detail
|
|
1018
|
+
if not detail.get('trace_id'):
|
|
1019
|
+
tree=self.query_one('#span-tree',Tree);tree.clear();tree.root.set_label('No trace selected')
|
|
1020
|
+
self.query_one('#span-info',RichLog).clear();self.span_signature=None
|
|
1021
|
+
return
|
|
1022
|
+
if detail.get('trace_id')!=self.feed.selected_trace:return
|
|
1023
|
+
spans=detail.get('spans',[])
|
|
1024
|
+
signature=(detail.get('trace_id'),tuple((item['span_id'],item['status'],item['end_unix_nano'],str(item['attributes'])) for item in spans))
|
|
1025
|
+
if signature==self.span_signature:return
|
|
1026
|
+
self.span_signature=signature
|
|
1027
|
+
tree=self.query_one('#span-tree',Tree)
|
|
1028
|
+
selected=tree.cursor_node.data.get('span_id') if tree.cursor_node and tree.cursor_node.data else None
|
|
1029
|
+
tree.clear()
|
|
1030
|
+
tree.root.set_label(Text('Trace '+str(detail.get('trace_id',''))[:12],style='bold'))
|
|
1031
|
+
nodes={};pending=list(spans)
|
|
1032
|
+
for _ in range(len(spans)+1):
|
|
1033
|
+
remaining=[]
|
|
1034
|
+
for item in pending:
|
|
1035
|
+
parent=item.get('parent_span_id')
|
|
1036
|
+
if parent and parent not in nodes and any(span['span_id']==parent for span in spans):remaining.append(item);continue
|
|
1037
|
+
attrs=item['attributes'];kind='MCP' if attrs.get('mcp.server.name') or attrs.get('mcp.tool.name') else 'MODEL' if attrs.get('gen_ai.operation.name')=='chat' or 'gen_ai.usage.input_tokens' in attrs else 'TOOL' if attrs.get('tool.name') else 'RUN'
|
|
1038
|
+
duration=None if attrs.get('observatory.duration_unknown') else (item['end_unix_nano']-item['start_unix_nano'])/1e6
|
|
1039
|
+
label=Text(f"{kind:5} ",style='#92b9eb');label.append(clean(item['name']));label.append(' · '+timing(duration),style='dim')
|
|
1040
|
+
if item['status']=='ERROR':label.append(' · ERROR',style='bold red')
|
|
1041
|
+
nodes[item['span_id']]=(nodes.get(parent) or tree.root).add(label,data=item,expand=True)
|
|
1042
|
+
if not remaining:break
|
|
1043
|
+
if len(remaining)==len(pending):
|
|
1044
|
+
for item in remaining:nodes[item['span_id']]=tree.root.add(Text(item['name']),data=item)
|
|
1045
|
+
break
|
|
1046
|
+
pending=remaining
|
|
1047
|
+
tree.root.expand()
|
|
1048
|
+
self.query_one('#trace-detail').border_subtitle=f"{len(spans)} / {detail.get('span_page',{}).get('trace_total',len(spans))} spans loaded"
|
|
1049
|
+
if getattr(self,'jump_span',None) in nodes:
|
|
1050
|
+
self.call_after_refresh(tree.move_cursor,nodes[self.jump_span]);self.jump_span=None
|
|
1051
|
+
elif nodes:
|
|
1052
|
+
self.call_after_refresh(tree.move_cursor,nodes.get(selected) or next(iter(nodes.values())))
|
|
1053
|
+
|
|
1054
|
+
def valid_row(self,event):
|
|
1055
|
+
table=event.data_table
|
|
1056
|
+
if not table.row_count:return None
|
|
1057
|
+
try:
|
|
1058
|
+
if table.coordinate_to_cell_key(table.cursor_coordinate).row_key!=event.row_key:return None
|
|
1059
|
+
except Exception:return None
|
|
1060
|
+
return self.table_data.get(table.id,{}).get(event.row_key.value)
|
|
1061
|
+
|
|
1062
|
+
@on(DataTable.RowHighlighted)
|
|
1063
|
+
def row_highlighted(self,event):
|
|
1064
|
+
item=self.valid_row(event)
|
|
1065
|
+
if not item:return
|
|
1066
|
+
name=event.data_table.id
|
|
1067
|
+
if name=='agent-table':self.selected_source=item['id'];self.show_agent()
|
|
1068
|
+
elif name=='activity-table':self.selected_activity=event.row_key.value;self.show_activity()
|
|
1069
|
+
elif name=='mcp-table':self.selected_mcp=item['id'];self.show_mcp()
|
|
1070
|
+
elif name=='trace-table':
|
|
1071
|
+
if self.feed.pinned_trace and self.data.detail.get('trace_id')!=self.feed.selected_trace:return
|
|
1072
|
+
if self.feed.selected_trace!=item['trace_id']:
|
|
1073
|
+
self.feed.selected_trace=item['trace_id'];self.feed.pinned_trace=True;self.feed.request_refresh()
|
|
1074
|
+
self.feed.selected_span_id=None
|
|
1075
|
+
self.query_one('#span-tree',Tree).root.set_label(Text('Loading selected trace…'))
|
|
1076
|
+
|
|
1077
|
+
@on(DataTable.RowSelected)
|
|
1078
|
+
def row_selected(self,event):
|
|
1079
|
+
item=self.valid_row(event)
|
|
1080
|
+
if not item:return
|
|
1081
|
+
if event.data_table.id!='agent-actions' and self.screen.has_class('compact'):
|
|
1082
|
+
self.action_expand()
|
|
1083
|
+
return
|
|
1084
|
+
if event.data_table.id=='agent-table':
|
|
1085
|
+
self.selected_source=item['id'];self.show_agent();self.query_one('#agent-detail',RichLog).focus()
|
|
1086
|
+
elif event.data_table.id=='agent-actions':
|
|
1087
|
+
self.selected_activity=item.get('id') or item['source_id'];self.action_page('activity');self.show_activity()
|
|
1088
|
+
elif event.data_table.id=='activity-table':self.query_one('#activity-detail',RichLog).focus()
|
|
1089
|
+
elif event.data_table.id=='mcp-table':self.query_one('#mcp-detail',RichLog).focus()
|
|
1090
|
+
elif event.data_table.id=='trace-table':self.query_one('#span-tree',Tree).focus()
|
|
1091
|
+
elif event.data_table.id=='host-table':self.query_one('#host-detail',RichLog).focus()
|
|
1092
|
+
|
|
1093
|
+
@on(Tree.NodeHighlighted,'#span-tree')
|
|
1094
|
+
def span_highlighted(self,event):
|
|
1095
|
+
self.show_span(event.node.data)
|
|
1096
|
+
|
|
1097
|
+
def show_span(self,span):
|
|
1098
|
+
if not span:return
|
|
1099
|
+
attrs=span['attributes']
|
|
1100
|
+
self.inspect('span-info',span['name'],[('Span',span['span_id']),('Parent',span.get('parent_span_id') or 'Root'),
|
|
1101
|
+
('Status',span['status']),('Service',span['service'])],[(key,value) for key,value in attrs.items()])
|
|
1102
|
+
|
|
1103
|
+
@on(Select.Changed,'#scope')
|
|
1104
|
+
def scope_changed(self,event):
|
|
1105
|
+
if event.value==Select.BLANK or event.value==self.scope_value:return
|
|
1106
|
+
self.scope_value=str(event.value);item=getattr(self,'source_map',{}).get(self.scope_value)
|
|
1107
|
+
self.selected_activity=self.selected_mcp=None
|
|
1108
|
+
self.feed.session_id=item.get('session_id') or '' if item else ''
|
|
1109
|
+
self.feed.service=item.get('service') or '' if item else ''
|
|
1110
|
+
self.feed.selected_trace=None;self.feed.pinned_trace=False;self.feed.request_refresh()
|
|
1111
|
+
self.feed.selected_span_id=None
|
|
1112
|
+
self.last_update=-1;self.refresh_data()
|
|
1113
|
+
|
|
1114
|
+
@on(Input.Changed,'.view-search')
|
|
1115
|
+
def search_changed(self,event):
|
|
1116
|
+
page=event.input.id.removeprefix('search-')
|
|
1117
|
+
value=event.value.strip()
|
|
1118
|
+
self.query_one('#clear-'+page,Button).disabled=not event.value
|
|
1119
|
+
if self.search_queries[page]==value:return
|
|
1120
|
+
self.search_queries[page]=value
|
|
1121
|
+
if page=='activity':self.selected_activity=None
|
|
1122
|
+
if page=='mcp':self.selected_mcp=None
|
|
1123
|
+
if page in self.search_timers:self.search_timers.pop(page).stop()
|
|
1124
|
+
def apply():
|
|
1125
|
+
if not self.query('#health'):return
|
|
1126
|
+
attr={'activity':'activity_query','mcp':'mcp_query','traces':'query'}.get(page)
|
|
1127
|
+
if attr:
|
|
1128
|
+
setattr(self.feed,attr,value)
|
|
1129
|
+
if page=='traces':
|
|
1130
|
+
self.feed.selected_trace=None;self.feed.selected_span_id=None;self.feed.pinned_trace=False
|
|
1131
|
+
self.feed.request_refresh()
|
|
1132
|
+
self.last_update=-1;self.refresh_data()
|
|
1133
|
+
if page in {'activity','mcp','traces'}:self.search_timers[page]=self.set_timer(.3,apply)
|
|
1134
|
+
else:apply()
|
|
1135
|
+
self.last_update=-1;self.refresh_data()
|
|
1136
|
+
|
|
1137
|
+
@on(Checkbox.Changed,'#reviews')
|
|
1138
|
+
def reviews_changed(self,event):
|
|
1139
|
+
self.selected_activity=self.selected_mcp=None
|
|
1140
|
+
self.feed.include_reviews=event.value;self.feed.selected_trace=None;self.feed.pinned_trace=False;self.feed.request_refresh()
|
|
1141
|
+
self.feed.selected_span_id=None
|
|
1142
|
+
|
|
1143
|
+
@on(TabbedContent.TabActivated,'#pages')
|
|
1144
|
+
def page_activated(self,event):
|
|
1145
|
+
self.screen.set_class(event.pane.has_class('expanded'),'inspecting')
|
|
1146
|
+
|
|
1147
|
+
@on(Button.Pressed)
|
|
1148
|
+
def button_pressed(self,event):
|
|
1149
|
+
if event.button.id=='pause':self.action_pause()
|
|
1150
|
+
elif event.button.id=='history-back':self.action_history_back()
|
|
1151
|
+
elif event.button.id.startswith(('older-','newer-','latest-')):
|
|
1152
|
+
direction,page=event.button.id.split('-',1)
|
|
1153
|
+
self.action_history(direction,page)
|
|
1154
|
+
elif event.button.id.startswith('expand-'):self.action_expand()
|
|
1155
|
+
elif event.button.id.startswith('errors-'):
|
|
1156
|
+
page=event.button.id.removeprefix('errors-')
|
|
1157
|
+
if page=='activity':self.selected_activity=None
|
|
1158
|
+
if page=='mcp':self.selected_mcp=None
|
|
1159
|
+
if page in self.errors_only:self.errors_only.remove(page)
|
|
1160
|
+
else:self.errors_only.add(page)
|
|
1161
|
+
event.button.set_class(page in self.errors_only,'-on')
|
|
1162
|
+
event.button.label='Errors ●' if page in self.errors_only else 'Errors'
|
|
1163
|
+
self.last_update=-1;self.refresh_data()
|
|
1164
|
+
elif event.button.id.startswith('clear-'):
|
|
1165
|
+
field=self.query_one('#search-'+event.button.id.removeprefix('clear-'),Input)
|
|
1166
|
+
field.value='';field.focus()
|
|
1167
|
+
elif event.button.id=='agent-open':self.action_focus_agent()
|
|
1168
|
+
elif event.button.id in {'activity-trace','mcp-trace'}:self.action_open_trace()
|
|
1169
|
+
elif event.button.id in {'activity-copy','mcp-copy'}:self.action_copy()
|
|
1170
|
+
|
|
1171
|
+
def action_pause(self):
|
|
1172
|
+
self.feed.paused=not self.feed.paused;self.query_one('#pause',Button).label='Resume' if self.feed.paused else 'Pause';self.feed.request_refresh();self.refresh_data()
|
|
1173
|
+
|
|
1174
|
+
def action_search(self):
|
|
1175
|
+
page=self.query_one('#pages',TabbedContent).active
|
|
1176
|
+
if page in self.search_queries:
|
|
1177
|
+
self.collapse(page)
|
|
1178
|
+
self.query_one('#search-'+page,Input).focus()
|
|
1179
|
+
def action_back(self):
|
|
1180
|
+
if self.screen.selections:
|
|
1181
|
+
self.screen.clear_selection();return
|
|
1182
|
+
page=self.query_one('#pages',TabbedContent).active
|
|
1183
|
+
if isinstance(self.focused,Input) or self.query_one('#'+page).has_class('expanded'):
|
|
1184
|
+
self.action_page(page)
|
|
1185
|
+
elif self.navigation_history:
|
|
1186
|
+
self.action_history_back()
|
|
1187
|
+
else:self.action_page(page)
|
|
1188
|
+
|
|
1189
|
+
def remember_location(self):
|
|
1190
|
+
page=self.query_one('#pages',TabbedContent).active
|
|
1191
|
+
self.navigation_history.append({'page':page,'expanded':self.query_one('#'+page).has_class('expanded'),
|
|
1192
|
+
'focus':self.focused.id if self.focused else None,
|
|
1193
|
+
'scroll':{widget.id:widget.scroll_offset for widget in self.query('#'+page+' RichLog, #'+page+' DataTable')}})
|
|
1194
|
+
self.query_one('#history-back',Button).disabled=False
|
|
1195
|
+
|
|
1196
|
+
def action_history_back(self):
|
|
1197
|
+
if not self.navigation_history:return
|
|
1198
|
+
location=self.navigation_history.pop()
|
|
1199
|
+
self.action_page(location['page'])
|
|
1200
|
+
if location['expanded']:self.action_expand()
|
|
1201
|
+
def restore():
|
|
1202
|
+
if location['focus']:self.query_one('#'+location['focus']).focus()
|
|
1203
|
+
for name,position in location['scroll'].items():
|
|
1204
|
+
self.query_one('#'+name).scroll_to(x=position.x,y=position.y,animate=False)
|
|
1205
|
+
self.call_after_refresh(restore)
|
|
1206
|
+
self.query_one('#history-back',Button).disabled=not self.navigation_history
|
|
1207
|
+
|
|
1208
|
+
def action_history(self,direction,page=None):
|
|
1209
|
+
page=page or self.query_one('#pages',TabbedContent).active
|
|
1210
|
+
if page not in self.feed.history:return
|
|
1211
|
+
self.screen.clear_selection()
|
|
1212
|
+
old=self.selected_activity,self.selected_mcp
|
|
1213
|
+
if page=='activity':self.selected_activity=None
|
|
1214
|
+
if page=='mcp':self.selected_mcp=None
|
|
1215
|
+
if not self.feed.navigate_history(page,direction):
|
|
1216
|
+
self.selected_activity,self.selected_mcp=old
|
|
1217
|
+
return
|
|
1218
|
+
self.last_update=-1;self.refresh_data()
|
|
1219
|
+
|
|
1220
|
+
def copy_to_clipboard(self,text):
|
|
1221
|
+
"""Route all copy actions, including search inputs, to the OS clipboard."""
|
|
1222
|
+
self._clipboard=text # Keep Textual's in-app paste buffer in sync.
|
|
1223
|
+
if text:self._copy_in_background(text)
|
|
1224
|
+
|
|
1225
|
+
@work(group='clipboard',exclusive=True)
|
|
1226
|
+
async def _copy_in_background(self,text):
|
|
1227
|
+
method=await write_system_clipboard(text)
|
|
1228
|
+
if method:
|
|
1229
|
+
self.notify(f'Copied to {method} clipboard.')
|
|
1230
|
+
else:
|
|
1231
|
+
super().copy_to_clipboard(text)
|
|
1232
|
+
self.notify('Copy sent to terminal; clipboard support depends on your terminal.')
|
|
1233
|
+
|
|
1234
|
+
def action_copy(self):
|
|
1235
|
+
selected=self.screen.get_selected_text()
|
|
1236
|
+
if selected:
|
|
1237
|
+
self.copy_to_clipboard(selected);return
|
|
1238
|
+
page=self.query_one('#pages',TabbedContent).active
|
|
1239
|
+
if page=='mcp':
|
|
1240
|
+
item=self.table_data.get('mcp-table',{}).get(self.selected_mcp)
|
|
1241
|
+
elif page=='activity':
|
|
1242
|
+
item=self.table_data.get('activity-table',{}).get(self.selected_activity)
|
|
1243
|
+
else:
|
|
1244
|
+
name={'agents':'agent-detail','traces':'span-info','host':'host-detail','output':'agent-output'}[page]
|
|
1245
|
+
self.copy_to_clipboard(self.query_one('#'+name,SelectableRichLog).plain_text)
|
|
1246
|
+
return
|
|
1247
|
+
if not item:
|
|
1248
|
+
self.notify('Select an activity or MCP call to copy.')
|
|
1249
|
+
return
|
|
1250
|
+
self.copy_to_clipboard(copy_event_text(item))
|
|
1251
|
+
def collapse(self,page):
|
|
1252
|
+
self.query_one('#'+page,TabPane).remove_class('expanded')
|
|
1253
|
+
self.screen.remove_class('inspecting')
|
|
1254
|
+
for button in self.query('#'+page+' .expand-button'):button.label=Text('Expand · e')
|
|
1255
|
+
self.call_after_refresh(self.reflow_inspectors)
|
|
1256
|
+
def action_expand(self):
|
|
1257
|
+
page=self.query_one('#pages',TabbedContent).active
|
|
1258
|
+
if page not in self.search_queries:return
|
|
1259
|
+
pane=self.query_one('#'+page,TabPane)
|
|
1260
|
+
if pane.has_class('expanded'):
|
|
1261
|
+
self.action_back();return
|
|
1262
|
+
pane.add_class('expanded')
|
|
1263
|
+
self.screen.add_class('inspecting')
|
|
1264
|
+
self.call_after_refresh(self.reflow_inspectors)
|
|
1265
|
+
for button in self.query('#'+page+' .expand-button'):button.label=Text('Back · e')
|
|
1266
|
+
target={'agents':'agent-detail','activity':'activity-detail','mcp':'mcp-detail','traces':'span-tree','host':'host-detail'}[page]
|
|
1267
|
+
self.query_one('#'+target).focus()
|
|
1268
|
+
self.notify('Expanded detail · Esc to return',timeout=2)
|
|
1269
|
+
def action_focus_agent(self):
|
|
1270
|
+
if self.selected_source:
|
|
1271
|
+
self.query_one('#scope',Select).value=self.selected_source
|
|
1272
|
+
self.action_page('activity')
|
|
1273
|
+
def action_reviews(self):self.query_one('#reviews',Checkbox).toggle()
|
|
1274
|
+
def action_all(self):
|
|
1275
|
+
self.selected_activity=self.selected_mcp=None
|
|
1276
|
+
self.query_one('#scope',Select).value='all'
|
|
1277
|
+
self.errors_only.clear()
|
|
1278
|
+
for button in self.query('.error-filter'):
|
|
1279
|
+
button.remove_class('-on');button.label='Errors'
|
|
1280
|
+
for page in self.search_queries:self.query_one('#search-'+page,Input).value=''
|
|
1281
|
+
self.last_update=-1;self.refresh_data()
|
|
1282
|
+
def action_page(self,page):
|
|
1283
|
+
if page=='output' and not self.agent:return
|
|
1284
|
+
self.screen.clear_selection()
|
|
1285
|
+
self.collapse(page)
|
|
1286
|
+
self.query_one('#pages',TabbedContent).active=page
|
|
1287
|
+
target={'agents':'agent-table','activity':'activity-table','mcp':'mcp-table','traces':'trace-table','host':'host-table','output':'agent-output'}[page]
|
|
1288
|
+
self.query_one('#'+target).focus()
|
|
1289
|
+
def action_browser(self):webbrowser.open(self.feed.api.endpoint)
|
|
1290
|
+
def action_open_trace(self):
|
|
1291
|
+
page=self.query_one('#pages',TabbedContent).active
|
|
1292
|
+
if page not in {'activity','mcp'}:return
|
|
1293
|
+
item=self.table_data.get('mcp-table',{}).get(self.selected_mcp) if page=='mcp' else self.table_data.get('activity-table',{}).get(self.selected_activity)
|
|
1294
|
+
if not item or not item.get('trace_id'):
|
|
1295
|
+
self.notify('This action has no linked trace.');return
|
|
1296
|
+
self.remember_location()
|
|
1297
|
+
self.jump_span=item.get('span_id')
|
|
1298
|
+
self.feed.selected_span_id=self.jump_span
|
|
1299
|
+
self.feed.selected_trace=item['trace_id'];self.feed.pinned_trace=True;self.feed.request_refresh();self.action_page('traces')
|