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.
@@ -0,0 +1,525 @@
1
+ """Receiver lifecycle and live data for the terminal cockpit."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ import textwrap
16
+ import unicodedata
17
+ import urllib.error
18
+ import urllib.request
19
+ import webbrowser
20
+ from collections import deque
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from urllib.parse import urlencode, urlsplit
24
+
25
+
26
+ class CockpitError(Exception):
27
+ pass
28
+
29
+
30
+ def clean(value):
31
+ text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", str(value))
32
+ return "".join(char for char in text if unicodedata.category(char) not in {"Cc", "Cf"})
33
+
34
+
35
+ def clipped(value, width):
36
+ result, used = [], 0
37
+ for char in clean(value):
38
+ size = 0 if unicodedata.combining(char) else 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
39
+ if used + size > max(0, width):
40
+ break
41
+ result.append(char)
42
+ used += size
43
+ return "".join(result)
44
+
45
+
46
+ def number(value):
47
+ value = float(value or 0)
48
+ if abs(value) >= 1_000_000:
49
+ return f"{value / 1_000_000:.1f}m"
50
+ if abs(value) >= 10_000:
51
+ return f"{value / 1_000:.1f}k"
52
+ return f"{value:,.0f}"
53
+
54
+
55
+ def trace_name(trace):
56
+ if trace.get("task"):
57
+ return trace["task"]
58
+ if trace.get("models"):
59
+ return ", ".join(trace["models"])
60
+ return trace.get("name") or trace.get("trace_id", "Agent run")[:12]
61
+
62
+
63
+ class Api:
64
+ def __init__(self, endpoint, token=None):
65
+ parsed = urlsplit(endpoint)
66
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.query or parsed.fragment:
67
+ raise CockpitError("Use a receiver HTTP address for --endpoint.")
68
+ self.endpoint = endpoint.rstrip("/")
69
+ self.token = token
70
+
71
+ def get(self, path):
72
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
73
+ request = urllib.request.Request(self.endpoint + path, headers=headers)
74
+ with urllib.request.urlopen(request, timeout=3) as response:
75
+ return json.load(response)
76
+
77
+
78
+ class LocalReceiver:
79
+ def __init__(self, db_path, port, token, local_agents=True, metadata_only=False):
80
+ from http.server import ThreadingHTTPServer
81
+ from .core import Store
82
+ from .server import handler_for
83
+ self.store = Store(db_path, async_derived_refresh=True)
84
+ try:
85
+ self.server = ThreadingHTTPServer(("127.0.0.1", port), handler_for(self.store, token, quiet=True))
86
+ except OSError:
87
+ self.store.close()
88
+ raise
89
+ self.thread = threading.Thread(target=self.server.serve_forever, name="observatory-receiver", daemon=True)
90
+ self.thread.start()
91
+ self.local = None
92
+ if local_agents:
93
+ from .local_agents import LocalAgents
94
+ self.local = LocalAgents(self.store, capture_details=not metadata_only).start()
95
+ self.store.local_agent_monitor = self.local
96
+
97
+ def close(self):
98
+ if self.local:
99
+ self.local.close()
100
+ self.server.shutdown()
101
+ self.server.server_close()
102
+ self.thread.join()
103
+ self.store.close()
104
+
105
+
106
+ class AgentProcess:
107
+ def __init__(self, target, arguments, endpoint, service, python, module=False, capture_content=False):
108
+ command = [python, "-u", "-m", "observatory", "run", "--endpoint", endpoint, "--service", service]
109
+ if module:
110
+ command.append("--module")
111
+ if capture_content:
112
+ command.append("--capture-content")
113
+ command.extend([target, *arguments])
114
+ self.lines = deque(maxlen=400)
115
+ self.lines.append("Starting " + clean(target))
116
+ self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
117
+ text=True, encoding="utf-8", errors="replace",
118
+ start_new_session=os.name != "nt")
119
+ self.thread = threading.Thread(target=self._read, name="observatory-agent-output", daemon=True)
120
+ self.thread.start()
121
+
122
+ def _read(self):
123
+ with self.process.stdout:
124
+ for line in self.process.stdout:
125
+ self.lines.append(clean(line.rstrip()))
126
+
127
+ def close(self):
128
+ if self.process.poll() is None:
129
+ if os.name != "nt":
130
+ try:
131
+ os.killpg(self.process.pid, signal.SIGINT)
132
+ except ProcessLookupError:
133
+ pass
134
+ else:
135
+ self.process.terminate()
136
+ try:
137
+ self.process.wait(timeout=4)
138
+ except subprocess.TimeoutExpired:
139
+ if os.name != "nt":
140
+ try:
141
+ os.killpg(self.process.pid, signal.SIGKILL)
142
+ except ProcessLookupError:
143
+ pass
144
+ else:
145
+ self.process.kill()
146
+ self.process.wait()
147
+ self.thread.join(timeout=1)
148
+
149
+
150
+ @dataclass
151
+ class Snapshot:
152
+ totals: dict = field(default_factory=dict)
153
+ agents: list = field(default_factory=list)
154
+ traces: list = field(default_factory=list)
155
+ detail: dict = field(default_factory=dict)
156
+ findings: list = field(default_factory=list)
157
+ error: str = ""
158
+ updated: float = 0
159
+ local_status: dict = field(default_factory=dict)
160
+ activity: list = field(default_factory=list)
161
+ sessions: list = field(default_factory=list)
162
+ mcp: list = field(default_factory=list)
163
+ mcp_available: bool = True
164
+ activity_results: list | None = None
165
+ mcp_results: list | None = None
166
+ queries: dict = field(default_factory=dict)
167
+ pagination: dict = field(default_factory=dict)
168
+
169
+
170
+ class Feed:
171
+ def __init__(self, api):
172
+ self.api = api
173
+ self.snapshot = Snapshot()
174
+ self.service = ""
175
+ self.query = ""
176
+ self.activity_query = ""
177
+ self.mcp_query = ""
178
+ self.errors_only = False
179
+ self.selected_trace = None
180
+ self.selected_span_id = None
181
+ self.session_id = ''
182
+ self.include_reviews = False
183
+ self.pinned_trace = False
184
+ self.extra_cache = None
185
+ self.extra_updated = 0
186
+ self.extra_scope = None
187
+ self.paused = False
188
+ self.history = {page: [None] for page in ('activity','mcp','traces')}
189
+ self.history_scopes = {}
190
+ self.refresh_once = False
191
+ self.stop = threading.Event()
192
+ self.wake = threading.Event()
193
+ self.thread = threading.Thread(target=self._loop, name="observatory-terminal-feed", daemon=True)
194
+
195
+ def refresh(self):
196
+ service, query, errors_only, selected = self.service, self.query, self.errors_only, self.selected_trace
197
+ activity_query, mcp_query = self.activity_query, self.mcp_query
198
+ requested_trace, requested_span = selected, self.selected_span_id
199
+ session_id, include_reviews = self.session_id, self.include_reviews
200
+ for page, text in (('activity',activity_query),('mcp',mcp_query),('traces',query)):
201
+ scope=(service,session_id,text,include_reviews)
202
+ if self.history_scopes.get(page)!=scope:
203
+ self.history[page]=[None]
204
+ self.history_scopes[page]=scope
205
+ history={page:tuple(cursors) for page,cursors in self.history.items()}
206
+ pagination={}
207
+ def page_result(page, response):
208
+ pagination[page]={'page':len(history[page]), 'next_cursor':response.get('next_cursor'),
209
+ 'supported':'next_cursor' in response}
210
+ try:
211
+ local_status = self.api.get("/api/health").get("local_agents", {})
212
+ totals = self.api.get("/api/metrics")
213
+ agents = self.api.get("/api/connections")["connections"]
214
+ try:
215
+ activity_params = {"limit": 200, "service": service, "session_id": session_id,
216
+ "include_reviews":int(include_reviews)}
217
+ activity_page = self.api.get("/api/activity?" + urlencode(activity_params))
218
+ activity = activity_page['activity']
219
+ if activity_query or history['activity'][-1]:
220
+ activity_page = self.api.get("/api/activity?" + urlencode({**activity_params,
221
+ "q":activity_query,"cursor":history['activity'][-1] or ''}))
222
+ activity_results = activity_page['activity']
223
+ page_result('activity',activity_page)
224
+ except urllib.error.HTTPError as exc:
225
+ if exc.code != 404:
226
+ raise
227
+ activity, activity_results = [], []
228
+ scope = (service, session_id, mcp_query, include_reviews, history['mcp'])
229
+ if self.extra_cache is None or scope != self.extra_scope or time.monotonic()-self.extra_updated>3:
230
+ sessions = self.api.get('/api/sessions?limit=500')['sessions']
231
+ try:
232
+ mcp_params = {'limit':100,'service':service,'session_id':session_id,
233
+ 'include_reviews':int(include_reviews)}
234
+ mcp_page = self.api.get('/api/mcp?'+urlencode(mcp_params))
235
+ mcp = mcp_page['calls']
236
+ if mcp_query or history['mcp'][-1]:
237
+ mcp_page = self.api.get('/api/mcp?'+urlencode({**mcp_params,'q':mcp_query,
238
+ 'cursor':history['mcp'][-1] or ''}))
239
+ mcp_results = mcp_page['calls']
240
+ mcp_available = True
241
+ except urllib.error.HTTPError as exc:
242
+ if exc.code!=404:
243
+ raise
244
+ mcp,mcp_results,mcp_available,mcp_page=[],[],False,{}
245
+ self.extra_cache = sessions,mcp,mcp_results,mcp_available,mcp_page
246
+ self.extra_scope,self.extra_updated = scope,time.monotonic()
247
+ sessions,mcp,mcp_results,mcp_available,mcp_page=self.extra_cache
248
+ page_result('mcp',mcp_page)
249
+ params = {"limit": 100, "service": service, "session_id": session_id, "q": query,
250
+ "cursor":history['traces'][-1] or '', 'include_reviews':int(include_reviews)}
251
+ if errors_only:
252
+ params["status"] = "ERROR"
253
+ trace_page = self.api.get("/api/traces?" + urlencode(params))
254
+ traces = trace_page['traces']
255
+ page_result('traces',trace_page)
256
+ if not include_reviews:
257
+ traces = [item for item in traces if 'codex-review' not in item.get('services',[])]
258
+ activity = [item for item in activity if item.get('service')!='codex-review']
259
+ activity_results = [item for item in activity_results if item.get('service')!='codex-review']
260
+ mcp = [item for item in mcp if item.get('service')!='codex-review']
261
+ mcp_results = [item for item in mcp_results if item.get('service')!='codex-review']
262
+ traces,activity = traces[:100],activity[:200]
263
+ if not self.pinned_trace and selected not in {item["trace_id"] for item in traces}:
264
+ selected = traces[0]["trace_id"] if traces else None
265
+ detail = self.api.get("/api/traces/" + selected + "?span_limit=100") if selected else {}
266
+ if selected and requested_span:
267
+ wanted=requested_span
268
+ known={span['span_id'] for span in detail.get('spans',[])}
269
+ for _ in range(8):
270
+ if not wanted or wanted in known:break
271
+ page=self.api.get('/api/traces/'+selected+'/spans?'+urlencode({'limit':100,'q':wanted}))
272
+ span=next((item for item in page['spans'] if item['span_id']==wanted),None)
273
+ if not span:break
274
+ detail['spans'].append(span);known.add(wanted)
275
+ wanted=span.get('parent_span_id')
276
+ findings = detail.get("findings", [])
277
+ if not selected:
278
+ findings = self.api.get("/api/findings?limit=8")["findings"]
279
+ # A filter changed while the HTTP reads were in flight; discard the older view.
280
+ if (service, query, activity_query, mcp_query, errors_only, session_id, include_reviews) != (self.service, self.query, self.activity_query, self.mcp_query, self.errors_only, self.session_id, self.include_reviews):
281
+ self.wake.set()
282
+ return
283
+ if self.selected_trace!=requested_trace or self.selected_span_id!=requested_span or history!={page:tuple(cursors) for page,cursors in self.history.items()}:
284
+ self.wake.set()
285
+ return
286
+ self.selected_trace = selected
287
+ self.snapshot = Snapshot(totals, agents, traces, detail, findings, updated=time.time(), local_status=local_status, activity=activity,
288
+ sessions=sessions,mcp=mcp,mcp_available=mcp_available,
289
+ activity_results=activity_results[:200],mcp_results=mcp_results,
290
+ queries={'activity':activity_query,'mcp':mcp_query,'traces':query},pagination=pagination)
291
+ except Exception as exc:
292
+ if isinstance(exc, urllib.error.HTTPError) and exc.code == 401:
293
+ message = "Receiver requires authentication. Set OBSERVATORY_TOKEN and restart."
294
+ else:
295
+ message = "Receiver unavailable. Retrying: " + clean(str(exc))
296
+ old = self.snapshot
297
+ from dataclasses import replace
298
+ self.snapshot = replace(old,error=message)
299
+
300
+ def request_refresh(self):
301
+ self.refresh_once = True
302
+ self.wake.set()
303
+
304
+ def navigate_history(self, page, direction):
305
+ cursors=self.history[page]
306
+ next_cursor=self.snapshot.pagination.get(page,{}).get('next_cursor')
307
+ if direction=='older' and next_cursor:
308
+ self.history[page]=[*cursors,next_cursor]
309
+ elif direction=='newer' and len(cursors)>1:
310
+ self.history[page]=cursors[:-1]
311
+ elif direction=='latest':
312
+ self.history[page]=[None]
313
+ else:
314
+ return False
315
+ if page=='traces':
316
+ self.selected_trace=None;self.selected_span_id=None;self.pinned_trace=False
317
+ self.request_refresh()
318
+ return True
319
+
320
+ def _loop(self):
321
+ while not self.stop.is_set():
322
+ if not self.paused or self.refresh_once:
323
+ self.refresh_once = False
324
+ self.refresh()
325
+ self.wake.wait(1)
326
+ self.wake.clear()
327
+
328
+ def close(self):
329
+ self.stop.set()
330
+ self.wake.set()
331
+ self.thread.join(timeout=4)
332
+
333
+
334
+ def agent_rows(snapshot):
335
+ rows = [("", "ALL AGENTS", "")]
336
+ known = set()
337
+ local_groups = {}
338
+ for agent in snapshot.agents:
339
+ service = agent["service"]
340
+ known.add(service)
341
+ local = "local-session-log" in agent.get("integrations", [])
342
+ if local:
343
+ local_groups.setdefault(service, []).append(agent)
344
+ continue
345
+ state = ("RECENT" if agent["state"] == "running" else "IDLE") if local else (
346
+ "LIVE" if agent["state"] == "running" else "DONE" if agent["state"] == "stopped" else "OFFLINE")
347
+ rows.append((service, service, f"{state} · {number(agent['span_count'])} spans"))
348
+ for service, agents in local_groups.items():
349
+ state = "RECENT" if any(agent["state"] == "running" for agent in agents) else "IDLE"
350
+ rows.append((service, service, f"{state} · {len(agents)} sessions · {number(sum(agent['span_count'] for agent in agents))} spans"))
351
+ for service in snapshot.totals.get("services", []):
352
+ if service not in known:
353
+ rows.append((service, service, "OTLP / imported"))
354
+ return rows
355
+
356
+
357
+ def event_rows(snapshot):
358
+ rows = []
359
+ detail = snapshot.detail
360
+ for event in detail.get("progress", []):
361
+ rows.append((event.get("time_ns", 0), event.get("stage", "Progress"), event.get("message", "")))
362
+ for log in detail.get("logs", [])[:30]:
363
+ body = log.get("body", "")
364
+ rows.append((log.get("time_unix_nano", 0), log.get("severity_text") or log.get("event_name") or "LOG",
365
+ body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)))
366
+ for finding in snapshot.findings[:10]:
367
+ rows.append((finding.get("time_unix_nano", 0), finding.get("severity", "finding").upper(), finding.get("title", "Review finding")))
368
+ for span in detail.get("spans", []):
369
+ if span.get("scope_name") == "observatory.local_agents":
370
+ rows.append((span["end_unix_nano"], span["status"], span["name"]))
371
+ rows.sort(key=lambda item: item[0], reverse=True)
372
+ return [f"{time.strftime('%H:%M:%S', time.localtime(stamp/1e9)) if stamp else '--:--:--'} {label} {body}" for stamp, label, body in rows]
373
+
374
+
375
+ def span_rows(snapshot):
376
+ rows = []
377
+ for span in snapshot.detail.get("spans", []):
378
+ duration = (span["end_unix_nano"] - span["start_unix_nano"]) / 1e6
379
+ timing = " unknown" if span.get("attributes", {}).get("observatory.duration_unknown") else f"{duration:7.1f}ms"
380
+ rows.append(f"{span['status'][:7]:7} {timing} {span['name']}")
381
+ return rows
382
+
383
+
384
+ def activity_rows(snapshot):
385
+ rows = []
386
+ for item in snapshot.activity:
387
+ stamp = time.strftime('%H:%M:%S', time.localtime(item['time_unix_nano']/1e9))
388
+ state = str(item.get('result') or 'observed').upper()
389
+ rows.append(f"{stamp} {item['domain'][:7].upper():7} {state[:7]:7} {item['action']} {item['subject']}")
390
+ return rows
391
+
392
+
393
+ def wrapped(text, width):
394
+ lines = []
395
+ for line in str(text).splitlines():
396
+ lines.extend(textwrap.wrap(line, width=max(10, width), replace_whitespace=False) or [''])
397
+ return lines
398
+
399
+
400
+ def print_snapshot(feed, agent=None):
401
+ feed.refresh()
402
+ snapshot = feed.snapshot
403
+ totals = snapshot.totals
404
+ print("OBSERVATORY " + feed.api.endpoint)
405
+ if snapshot.local_status.get("enabled"):
406
+ mode = "bounded, redacted content" if snapshot.local_status.get('capture_content') else "metadata only"
407
+ print(f"Source: local Codex / Claude Code session logs · {snapshot.local_status.get('sources', 0)} files · {mode}")
408
+ print(f"Runs {number(totals.get('runs'))} Spans {number(totals.get('spans'))} "
409
+ f"Tokens {number(totals.get('input_tokens'))} in / {number(totals.get('output_tokens'))} out "
410
+ f"Errors {number(totals.get('errors'))} Known cost ${totals.get('known_cost_usd', 0):.4f}")
411
+ for title, rows in (
412
+ ("CONNECTED AGENTS", [f"{label} {state}" for _, label, state in agent_rows(snapshot)[1:]]),
413
+ ("RECENT TRACES", [f"{item['status']:10} {trace_name(item)} {number(item['input_tokens'])} in / {number(item['output_tokens'])} out" for item in snapshot.traces[:10]]),
414
+ ("SELECTED TRACE / SPANS", span_rows(snapshot)[:10]),
415
+ ("LIVE PROGRESS / ERRORS", event_rows(snapshot)[:8]),
416
+ ("RECENT ACTIVITY", activity_rows(snapshot)[:12]),
417
+ ("AGENT OUTPUT", list(agent.lines)[-5:] if agent else []),
418
+ ):
419
+ print("\n+-- " + title + " " + "-" * max(0, 66-len(title)))
420
+ for line in rows or ["Waiting for telemetry. Press c in the cockpit for connection commands."]:
421
+ print("| " + clipped(line, 100))
422
+ print("+" + "-" * 72)
423
+ if snapshot.error:
424
+ print(snapshot.error)
425
+ raise CockpitError(snapshot.error)
426
+
427
+
428
+ def open_terminal(command):
429
+ if sys.platform == "darwin":
430
+ script = tempfile.NamedTemporaryFile(mode="w", prefix="observatory-", suffix=".command", delete=False)
431
+ with script:
432
+ script.write("#!/bin/sh\nrm -- " + shlex.quote(script.name) + "\ncd " + shlex.quote(os.getcwd()) + "\nexec " + shlex.join(command) + "\n")
433
+ os.chmod(script.name, 0o700)
434
+ subprocess.Popen(["open", "-a", "Terminal", script.name])
435
+ return
436
+ if os.name == "nt":
437
+ terminal = shutil.which("wt.exe")
438
+ if terminal:
439
+ subprocess.Popen([terminal, "--title", "Observatory", "--startingDirectory", os.getcwd(), *command])
440
+ return
441
+ elif os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
442
+ for executable, prefix in (("gnome-terminal", ["--working-directory", os.getcwd(), "--"]),
443
+ ("konsole", ["--workdir", os.getcwd(), "-e"]),
444
+ ("x-terminal-emulator", ["-e"]), ("xterm", ["-e"])):
445
+ terminal = shutil.which(executable)
446
+ if terminal:
447
+ subprocess.Popen([terminal, *prefix, *command])
448
+ return
449
+ raise CockpitError("No graphical terminal is available. Run the cockpit in your current terminal, or use --once.")
450
+
451
+
452
+ def run_cockpit(*, db_path="observatory.db", port=14318, endpoint=None, service="my-agent", target=None,
453
+ arguments=(), python=None, module=False, capture_content=False, once=False, new_window=False, local_agents=True, metadata_only=False):
454
+ if not 1 <= port <= 65535:
455
+ raise CockpitError("Choose a port between 1 and 65535.")
456
+ if once and target:
457
+ raise CockpitError("--once prints a snapshot. Omit the agent filename, or run the interactive cockpit to launch it.")
458
+ if new_window:
459
+ command = [sys.executable, "-m", "observatory", "--db", str(Path(db_path).resolve()), "cockpit", "--port", str(port), "--service", service]
460
+ if endpoint:
461
+ command.extend(["--endpoint", endpoint])
462
+ if python:
463
+ command.extend(["--python", python])
464
+ if module:
465
+ command.append("--module")
466
+ if capture_content:
467
+ command.append("--capture-content")
468
+ if not local_agents:
469
+ command.append("--no-local-agents")
470
+ if metadata_only:
471
+ command.append("--metadata-only")
472
+ if once:
473
+ command.append("--once")
474
+ if target:
475
+ command.extend([str(Path(target).resolve()) if not module else target, *arguments])
476
+ open_terminal(command)
477
+ return
478
+ if not once and (not sys.stdin.isatty() or not sys.stdout.isatty()):
479
+ raise CockpitError("Run this command in an interactive terminal. Use --new-window to open one, or --once for a snapshot.")
480
+ if not once:
481
+ try:
482
+ from .terminal_ui import ObservatoryApp
483
+ except ImportError as exc:
484
+ raise CockpitError("The terminal UI needs Textual. Reinstall the CLI, or install this checkout with: python -m pip install .") from exc
485
+ token = os.environ.get("OBSERVATORY_TOKEN")
486
+ receiver = agent = feed = None
487
+ api = Api(endpoint or f"http://127.0.0.1:{port}", token)
488
+ try:
489
+ try:
490
+ health = api.get("/api/health")
491
+ if not health.get("ok"):
492
+ raise CockpitError("The endpoint is not a ready Observatory receiver.")
493
+ if metadata_only and health.get("local_agents", {}).get("capture_content"):
494
+ raise CockpitError("The existing receiver captures session content. Restart it with serve --metadata-only, then reconnect.")
495
+ except urllib.error.HTTPError as exc:
496
+ if exc.code == 401:
497
+ raise CockpitError("Receiver requires authentication. Set OBSERVATORY_TOKEN before starting.") from exc
498
+ raise CockpitError(f"The endpoint returned HTTP {exc.code}. Check the receiver address.") from exc
499
+ except (urllib.error.URLError, OSError) as exc:
500
+ if endpoint:
501
+ raise CockpitError("Cannot connect to the receiver at " + api.endpoint) from exc
502
+ try:
503
+ receiver = LocalReceiver(db_path, port, token, local_agents=local_agents, metadata_only=metadata_only)
504
+ except OSError as bind_error:
505
+ raise CockpitError(f"Cannot start a local receiver on port {port}: {bind_error}") from bind_error
506
+ feed = Feed(api)
507
+ if target:
508
+ try:
509
+ agent = AgentProcess(target, arguments, api.endpoint, service, python or sys.executable, module, capture_content)
510
+ except OSError as exc:
511
+ raise CockpitError("Cannot launch the agent: " + str(exc)) from exc
512
+ if once:
513
+ print_snapshot(feed, agent)
514
+ else:
515
+ ObservatoryApp(feed, agent, receiver is not None).run()
516
+ except KeyboardInterrupt:
517
+ pass
518
+ finally:
519
+ if agent is not None:
520
+ agent.close()
521
+ if feed is not None:
522
+ if feed.thread.is_alive():
523
+ feed.close()
524
+ if receiver is not None:
525
+ receiver.close()