agent_hypervisor 3.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.
Files changed (60) hide show
  1. agent_hypervisor-3.1.0.dist-info/METADATA +824 -0
  2. agent_hypervisor-3.1.0.dist-info/RECORD +60 -0
  3. agent_hypervisor-3.1.0.dist-info/WHEEL +4 -0
  4. agent_hypervisor-3.1.0.dist-info/entry_points.txt +2 -0
  5. agent_hypervisor-3.1.0.dist-info/licenses/LICENSE +21 -0
  6. hypervisor/__init__.py +160 -0
  7. hypervisor/api/__init__.py +7 -0
  8. hypervisor/api/models.py +285 -0
  9. hypervisor/api/server.py +742 -0
  10. hypervisor/audit/__init__.py +4 -0
  11. hypervisor/audit/commitment.py +76 -0
  12. hypervisor/audit/delta.py +135 -0
  13. hypervisor/audit/gc.py +99 -0
  14. hypervisor/cli/__init__.py +3 -0
  15. hypervisor/cli/formatters.py +99 -0
  16. hypervisor/cli/session_commands.py +200 -0
  17. hypervisor/constants.py +106 -0
  18. hypervisor/core.py +352 -0
  19. hypervisor/integrations/__init__.py +10 -0
  20. hypervisor/integrations/iatp_adapter.py +142 -0
  21. hypervisor/integrations/nexus_adapter.py +108 -0
  22. hypervisor/integrations/verification_adapter.py +122 -0
  23. hypervisor/liability/__init__.py +142 -0
  24. hypervisor/liability/attribution.py +86 -0
  25. hypervisor/liability/ledger.py +121 -0
  26. hypervisor/liability/quarantine.py +119 -0
  27. hypervisor/liability/slashing.py +80 -0
  28. hypervisor/liability/vouching.py +134 -0
  29. hypervisor/models.py +277 -0
  30. hypervisor/observability/__init__.py +27 -0
  31. hypervisor/observability/causal_trace.py +70 -0
  32. hypervisor/observability/event_bus.py +222 -0
  33. hypervisor/observability/prometheus_collector.py +248 -0
  34. hypervisor/observability/saga_span_exporter.py +341 -0
  35. hypervisor/providers.py +121 -0
  36. hypervisor/py.typed +0 -0
  37. hypervisor/reversibility/__init__.py +3 -0
  38. hypervisor/reversibility/registry.py +108 -0
  39. hypervisor/rings/__init__.py +21 -0
  40. hypervisor/rings/breach_detector.py +200 -0
  41. hypervisor/rings/classifier.py +78 -0
  42. hypervisor/rings/elevation.py +219 -0
  43. hypervisor/rings/enforcer.py +97 -0
  44. hypervisor/saga/__init__.py +22 -0
  45. hypervisor/saga/checkpoint.py +110 -0
  46. hypervisor/saga/dsl.py +190 -0
  47. hypervisor/saga/fan_out.py +126 -0
  48. hypervisor/saga/orchestrator.py +229 -0
  49. hypervisor/saga/schema.py +244 -0
  50. hypervisor/saga/state_machine.py +157 -0
  51. hypervisor/security/__init__.py +13 -0
  52. hypervisor/security/kill_switch.py +200 -0
  53. hypervisor/security/rate_limiter.py +190 -0
  54. hypervisor/session/__init__.py +194 -0
  55. hypervisor/session/intent_locks.py +118 -0
  56. hypervisor/session/isolation.py +37 -0
  57. hypervisor/session/sso.py +169 -0
  58. hypervisor/session/vector_clock.py +118 -0
  59. hypervisor/verification/__init__.py +3 -0
  60. hypervisor/verification/history.py +173 -0
@@ -0,0 +1,4 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+ # Public Preview — basic implementation
4
+ """Audit subpackage — delta engine, commitment, and GC."""
@@ -0,0 +1,76 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+ # Public Preview — basic implementation
4
+ """
5
+ Hash Commitment — stub implementation.
6
+
7
+ Public Preview: stores commitments in-memory only.
8
+ No blockchain anchoring.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC, datetime
15
+
16
+
17
+ @dataclass
18
+ class CommitmentRecord:
19
+ """Record of a Summary Hash commitment."""
20
+
21
+ session_id: str
22
+ hash_chain_root: str
23
+ participant_dids: list[str]
24
+ delta_count: int
25
+ committed_at: datetime = field(default_factory=lambda: datetime.now(UTC))
26
+ blockchain_tx_id: str | None = None
27
+ committed_to: str = "local"
28
+
29
+
30
+ class CommitmentEngine:
31
+ """
32
+ Simple in-memory commitment store.
33
+
34
+ Public Preview: stores commitments locally, no external anchoring.
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ self._commitments: dict[str, CommitmentRecord] = {}
39
+ self._batch_queue: list[CommitmentRecord] = []
40
+
41
+ def commit(
42
+ self,
43
+ session_id: str,
44
+ hash_chain_root: str,
45
+ participant_dids: list[str],
46
+ delta_count: int,
47
+ ) -> CommitmentRecord:
48
+ """Commit a session's Summary Hash."""
49
+ record = CommitmentRecord(
50
+ session_id=session_id,
51
+ hash_chain_root=hash_chain_root,
52
+ participant_dids=participant_dids,
53
+ delta_count=delta_count,
54
+ )
55
+ self._commitments[session_id] = record
56
+ return record
57
+
58
+ def verify(self, session_id: str, expected_root: str) -> bool:
59
+ """Verify a session's audit log root."""
60
+ record = self._commitments.get(session_id)
61
+ if not record:
62
+ return False
63
+ return record.hash_chain_root == expected_root
64
+
65
+ def queue_for_batch(self, record: CommitmentRecord) -> None:
66
+ """Queue a commitment (Public Preview: no-op)."""
67
+ self._batch_queue.append(record)
68
+
69
+ def flush_batch(self) -> list[CommitmentRecord]:
70
+ """Flush the batch queue."""
71
+ batch = list(self._batch_queue)
72
+ self._batch_queue.clear()
73
+ return batch
74
+
75
+ def get_commitment(self, session_id: str) -> CommitmentRecord | None:
76
+ return self._commitments.get(session_id)
@@ -0,0 +1,135 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+ """
4
+ Delta Audit Engine — tamper-evident append-only audit log.
5
+
6
+ Records VFS state changes as a SHA-256 hash chain where each entry
7
+ links to its predecessor, providing cryptographic tamper evidence.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime
16
+ from typing import Optional
17
+
18
+
19
+ @dataclass
20
+ class VFSChange:
21
+ """A single change within a delta."""
22
+
23
+ path: str
24
+ operation: str
25
+ content_hash: str | None = None
26
+ previous_hash: str | None = None
27
+ agent_did: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class SemanticDelta:
32
+ """A delta capturing VFS state changes at a single turn."""
33
+
34
+ delta_id: str
35
+ turn_id: int
36
+ session_id: str
37
+ agent_did: str
38
+ timestamp: datetime
39
+ changes: list[VFSChange]
40
+ parent_hash: str | None
41
+ delta_hash: str = ""
42
+
43
+ def _build_hash_input(self) -> str:
44
+ changes_data = [
45
+ {
46
+ "path": c.path,
47
+ "operation": c.operation,
48
+ "content_hash": c.content_hash,
49
+ "previous_hash": c.previous_hash,
50
+ "agent_did": c.agent_did,
51
+ }
52
+ for c in self.changes
53
+ ]
54
+ return json.dumps(
55
+ {
56
+ "delta_id": self.delta_id,
57
+ "turn_id": self.turn_id,
58
+ "session_id": self.session_id,
59
+ "agent_did": self.agent_did,
60
+ "timestamp": self.timestamp.isoformat(),
61
+ "parent_hash": self.parent_hash or "",
62
+ "changes": changes_data,
63
+ },
64
+ sort_keys=True,
65
+ )
66
+
67
+ def compute_hash(self) -> str:
68
+ """Compute SHA-256 hash covering all fields including changes and parent linkage."""
69
+ self.delta_hash = hashlib.sha256(self._build_hash_input().encode()).hexdigest()
70
+ return self.delta_hash
71
+
72
+ def verify_hash(self) -> bool:
73
+ """Recompute hash and compare to stored value without mutation."""
74
+ expected = hashlib.sha256(self._build_hash_input().encode()).hexdigest()
75
+ return expected == self.delta_hash
76
+
77
+
78
+ class DeltaEngine:
79
+ """Tamper-evident append-only audit log with SHA-256 hash chain verification."""
80
+
81
+ def __init__(self, session_id: str) -> None:
82
+ self.session_id = session_id
83
+ self._deltas: list[SemanticDelta] = []
84
+ self._turn_counter = 0
85
+
86
+ def capture(
87
+ self,
88
+ agent_did: str,
89
+ changes: list[VFSChange],
90
+ delta_id: str | None = None,
91
+ ) -> SemanticDelta:
92
+ """Capture a delta for a turn, chaining to previous entry."""
93
+ self._turn_counter += 1
94
+ parent_hash = self._deltas[-1].delta_hash if self._deltas else None
95
+ delta = SemanticDelta(
96
+ delta_id=delta_id or f"delta:{self._turn_counter}",
97
+ turn_id=self._turn_counter,
98
+ session_id=self.session_id,
99
+ agent_did=agent_did,
100
+ timestamp=datetime.now(UTC),
101
+ changes=changes,
102
+ parent_hash=parent_hash,
103
+ )
104
+ delta.compute_hash()
105
+ self._deltas.append(delta)
106
+ return delta
107
+
108
+ def compute_hash_chain_root(self) -> str | None:
109
+ """Return hash of last delta in the chain."""
110
+ if not self._deltas:
111
+ return None
112
+ return self._deltas[-1].delta_hash
113
+
114
+ def verify_chain(self) -> tuple[bool, Optional[str]]:
115
+ """Verify full chain integrity: hash correctness and parent linkage."""
116
+ if not self._deltas:
117
+ return True, None
118
+
119
+ previous_hash = None
120
+ for i, delta in enumerate(self._deltas):
121
+ if not delta.verify_hash():
122
+ return False, f"Entry {i} hash mismatch"
123
+ if delta.parent_hash != previous_hash:
124
+ return False, f"Entry {i} chain broken"
125
+ previous_hash = delta.delta_hash
126
+
127
+ return True, None
128
+
129
+ @property
130
+ def deltas(self) -> list[SemanticDelta]:
131
+ return list(self._deltas)
132
+
133
+ @property
134
+ def turn_count(self) -> int:
135
+ return self._turn_counter
hypervisor/audit/gc.py ADDED
@@ -0,0 +1,99 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+ # Public Preview — basic implementation
4
+ """
5
+ Ephemeral Session Data Garbage Collection — stub implementation.
6
+
7
+ Public Preview: GC is a no-op. Data is retained in-memory for
8
+ session lifetime only.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC, datetime
15
+ from typing import Any
16
+
17
+
18
+ @dataclass
19
+ class GCResult:
20
+ """Result of a garbage collection run."""
21
+
22
+ session_id: str
23
+ retained_deltas: int
24
+ retained_hash: bool
25
+ purged_vfs_files: int
26
+ purged_caches: int
27
+ storage_before_bytes: int
28
+ storage_after_bytes: int
29
+ gc_at: datetime = field(default_factory=lambda: datetime.now(UTC))
30
+
31
+ @property
32
+ def storage_saved_bytes(self) -> int:
33
+ return self.storage_before_bytes - self.storage_after_bytes
34
+
35
+ @property
36
+ def savings_pct(self) -> float:
37
+ if self.storage_before_bytes == 0:
38
+ return 0.0
39
+ return (self.storage_saved_bytes / self.storage_before_bytes) * 100
40
+
41
+
42
+ @dataclass
43
+ class RetentionPolicy:
44
+ """Configuration for what to retain after GC."""
45
+
46
+ delta_retention_days: int = 180
47
+ hash_retention: str = "permanent"
48
+ liability_snapshot: bool = True
49
+
50
+
51
+ class EphemeralGC:
52
+ """
53
+ GC stub (Public Preview: logs collection requests, no actual purge).
54
+ """
55
+
56
+ def __init__(self, policy: RetentionPolicy | None = None) -> None:
57
+ self.policy = policy or RetentionPolicy()
58
+ self._gc_history: list[GCResult] = []
59
+ self._purged_sessions: set[str] = set()
60
+
61
+ def collect(
62
+ self,
63
+ session_id: str,
64
+ vfs: Any = None,
65
+ delta_engine: Any = None,
66
+ vfs_file_count: int = 0,
67
+ cache_count: int = 0,
68
+ delta_count: int = 0,
69
+ estimated_vfs_bytes: int = 0,
70
+ estimated_cache_bytes: int = 0,
71
+ estimated_delta_bytes: int = 0,
72
+ ) -> GCResult:
73
+ """Log a GC request (Public Preview: no actual purge)."""
74
+ result = GCResult(
75
+ session_id=session_id,
76
+ retained_deltas=delta_count,
77
+ retained_hash=True,
78
+ purged_vfs_files=0,
79
+ purged_caches=0,
80
+ storage_before_bytes=estimated_vfs_bytes + estimated_cache_bytes + estimated_delta_bytes,
81
+ storage_after_bytes=estimated_vfs_bytes + estimated_cache_bytes + estimated_delta_bytes,
82
+ )
83
+ self._gc_history.append(result)
84
+ self._purged_sessions.add(session_id)
85
+ return result
86
+
87
+ def is_purged(self, session_id: str) -> bool:
88
+ return session_id in self._purged_sessions
89
+
90
+ def should_expire_deltas(self, delta_timestamp: datetime) -> bool:
91
+ return False
92
+
93
+ @property
94
+ def history(self) -> list[GCResult]:
95
+ return list(self._gc_history)
96
+
97
+ @property
98
+ def purged_session_count(self) -> int:
99
+ return len(self._purged_sessions)
@@ -0,0 +1,3 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+ """CLI for inspecting hypervisor session state."""
@@ -0,0 +1,99 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+ """Output formatters for CLI commands."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from typing import Any
9
+
10
+
11
+ def format_output(data: Any, fmt: str) -> str:
12
+ """Format data as table, json, or yaml."""
13
+ if fmt == "json":
14
+ return json.dumps(data, indent=2, default=str)
15
+ if fmt == "yaml":
16
+ return _to_yaml(data)
17
+ # Default: table
18
+ if isinstance(data, list):
19
+ return _format_table(data)
20
+ if isinstance(data, dict):
21
+ return _format_dict(data)
22
+ return str(data)
23
+
24
+
25
+ def _format_table(rows: list[dict[str, Any]]) -> str:
26
+ """Render a list of dicts as an aligned text table."""
27
+ if not rows:
28
+ return "No results."
29
+ keys = list(rows[0].keys())
30
+ widths = {k: max(len(k), *(len(str(r.get(k, ""))) for r in rows)) for k in keys}
31
+ header = " ".join(k.upper().ljust(widths[k]) for k in keys)
32
+ sep = " ".join("-" * widths[k] for k in keys)
33
+ lines = [header, sep]
34
+ for row in rows:
35
+ lines.append(" ".join(str(row.get(k, "")).ljust(widths[k]) for k in keys))
36
+ return "\n".join(lines)
37
+
38
+
39
+ def _format_dict(d: dict[str, Any]) -> str:
40
+ """Render a dict as key-value pairs."""
41
+ if not d:
42
+ return "No data."
43
+ width = max(len(str(k)) for k in d)
44
+ lines = []
45
+ for k, v in d.items():
46
+ if isinstance(v, list):
47
+ lines.append(f"{str(k).ljust(width)}: ({len(v)} items)")
48
+ for item in v:
49
+ if isinstance(item, dict):
50
+ parts = ", ".join(f"{ik}={iv}" for ik, iv in item.items())
51
+ lines.append(f" - {parts}")
52
+ else:
53
+ lines.append(f" - {item}")
54
+ else:
55
+ lines.append(f"{str(k).ljust(width)}: {v}")
56
+ return "\n".join(lines)
57
+
58
+
59
+ def _to_yaml(data: Any, indent: int = 0) -> str:
60
+ """Minimal YAML serializer (no external dependency)."""
61
+ prefix = " " * indent
62
+ if isinstance(data, dict):
63
+ if not data:
64
+ return f"{prefix}{{}}"
65
+ lines = []
66
+ for k, v in data.items():
67
+ if isinstance(v, (dict, list)):
68
+ lines.append(f"{prefix}{k}:")
69
+ lines.append(_to_yaml(v, indent + 1))
70
+ else:
71
+ lines.append(f"{prefix}{k}: {_yaml_scalar(v)}")
72
+ return "\n".join(lines)
73
+ if isinstance(data, list):
74
+ if not data:
75
+ return f"{prefix}[]"
76
+ lines = []
77
+ for item in data:
78
+ if isinstance(item, dict):
79
+ first = True
80
+ for k, v in item.items():
81
+ tag = "- " if first else " "
82
+ first = False
83
+ if isinstance(v, (dict, list)):
84
+ lines.append(f"{prefix}{tag}{k}:")
85
+ lines.append(_to_yaml(v, indent + 2))
86
+ else:
87
+ lines.append(f"{prefix}{tag}{k}: {_yaml_scalar(v)}")
88
+ else:
89
+ lines.append(f"{prefix}- {_yaml_scalar(item)}")
90
+ return "\n".join(lines)
91
+ return f"{prefix}{_yaml_scalar(data)}"
92
+
93
+
94
+ def _yaml_scalar(v: Any) -> str:
95
+ if v is None:
96
+ return "null"
97
+ if isinstance(v, bool):
98
+ return "true" if v else "false"
99
+ return str(v)
@@ -0,0 +1,200 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+ """Session inspection CLI commands."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+ from typing import Any
10
+
11
+ from hypervisor.cli.formatters import format_output
12
+ from hypervisor.core import Hypervisor, ManagedSession
13
+ from hypervisor.security.kill_switch import KillReason, KillSwitch
14
+
15
+
16
+ def _build_session_summary(managed: ManagedSession) -> dict[str, Any]:
17
+ """Build a summary dict for a single session."""
18
+ sso = managed.sso
19
+ return {
20
+ "session_id": sso.session_id,
21
+ "state": sso.state.value,
22
+ "participants": sso.participant_count,
23
+ "consistency": sso.consistency_mode.value,
24
+ "created_at": sso.created_at.isoformat(),
25
+ }
26
+
27
+
28
+ def _build_session_detail(managed: ManagedSession) -> dict[str, Any]:
29
+ """Build a detailed inspection dict for a session."""
30
+ sso = managed.sso
31
+
32
+ participants = [
33
+ {
34
+ "agent_did": p.agent_did,
35
+ "ring": p.ring.value,
36
+ "eff_score": round(p.eff_score, 4),
37
+ "sigma_raw": round(p.sigma_raw, 4),
38
+ "is_active": p.is_active,
39
+ }
40
+ for p in sso.participants
41
+ ]
42
+
43
+ # Saga steps
44
+ saga_steps: list[dict[str, Any]] = []
45
+ for saga in managed.saga._sagas.values():
46
+ for step in saga.steps:
47
+ saga_steps.append({
48
+ "saga_id": saga.saga_id,
49
+ "step_id": step.step_id,
50
+ "action_id": step.action_id,
51
+ "agent_did": step.agent_did,
52
+ "state": step.state.value,
53
+ "error": step.error,
54
+ })
55
+
56
+ # Audit deltas
57
+ audit_entries = [
58
+ {
59
+ "delta_id": d.delta_id,
60
+ "turn_id": d.turn_id,
61
+ "agent_did": d.agent_did,
62
+ "timestamp": d.timestamp.isoformat(),
63
+ "changes": len(d.changes),
64
+ }
65
+ for d in managed.delta_engine.deltas
66
+ ]
67
+
68
+ # Resource usage
69
+ resource_usage = {
70
+ "vfs_files": sso.vfs.file_count,
71
+ "vfs_snapshots": sso.vfs.snapshot_count,
72
+ "audit_turns": managed.delta_engine.turn_count,
73
+ }
74
+
75
+ return {
76
+ "session_id": sso.session_id,
77
+ "state": sso.state.value,
78
+ "consistency_mode": sso.consistency_mode.value,
79
+ "created_at": sso.created_at.isoformat(),
80
+ "terminated_at": sso.terminated_at.isoformat() if sso.terminated_at else None,
81
+ "participants": participants,
82
+ "saga_steps": saga_steps,
83
+ "resource_usage": resource_usage,
84
+ "audit_log": audit_entries,
85
+ }
86
+
87
+
88
+ def cmd_list(hv: Hypervisor, fmt: str) -> str:
89
+ """List all active sessions."""
90
+ sessions = hv.active_sessions
91
+ if not sessions:
92
+ return "No active sessions."
93
+ rows = [_build_session_summary(m) for m in sessions]
94
+ return format_output(rows, fmt)
95
+
96
+
97
+ def cmd_inspect(hv: Hypervisor, session_id: str, fmt: str) -> str:
98
+ """Inspect a single session in detail."""
99
+ managed = hv.get_session(session_id)
100
+ if managed is None:
101
+ return f"Error: session '{session_id}' not found."
102
+ detail = _build_session_detail(managed)
103
+ return format_output(detail, fmt)
104
+
105
+
106
+ def cmd_kill(
107
+ hv: Hypervisor,
108
+ session_id: str,
109
+ fmt: str,
110
+ kill_switch: KillSwitch | None = None,
111
+ ) -> str:
112
+ """Trigger kill switch on all agents in a session."""
113
+ managed = hv.get_session(session_id)
114
+ if managed is None:
115
+ return f"Error: session '{session_id}' not found."
116
+
117
+ ks = kill_switch or KillSwitch()
118
+ results = []
119
+ for p in managed.sso.participants:
120
+ result = ks.kill(
121
+ agent_did=p.agent_did,
122
+ session_id=session_id,
123
+ reason=KillReason.MANUAL,
124
+ details="Killed via CLI",
125
+ )
126
+ managed.sso.leave(p.agent_did)
127
+ results.append({
128
+ "kill_id": result.kill_id,
129
+ "agent_did": result.agent_did,
130
+ "reason": result.reason.value,
131
+ "timestamp": result.timestamp.isoformat(),
132
+ })
133
+
134
+ return format_output(results, fmt)
135
+
136
+
137
+ def build_parser(
138
+ parent: argparse._SubParsersAction | None = None,
139
+ ) -> argparse.ArgumentParser:
140
+ """Build the 'session' sub-command parser."""
141
+ if parent is not None:
142
+ parser = parent.add_parser("session", help="Inspect session state")
143
+ else:
144
+ parser = argparse.ArgumentParser(prog="hypervisor session")
145
+
146
+ parser.add_argument(
147
+ "--format",
148
+ choices=["table", "json", "yaml"],
149
+ default="table",
150
+ dest="output_format",
151
+ help="Output format (default: table)",
152
+ )
153
+
154
+ sub = parser.add_subparsers(dest="session_command")
155
+
156
+ sub.add_parser("list", help="List all active sessions")
157
+
158
+ inspect_p = sub.add_parser("inspect", help="Show detailed session state")
159
+ inspect_p.add_argument("session_id", help="Session ID to inspect")
160
+
161
+ kill_p = sub.add_parser("kill", help="Trigger kill switch on a session")
162
+ kill_p.add_argument("session_id", help="Session ID to kill")
163
+
164
+ return parser
165
+
166
+
167
+ def dispatch(
168
+ args: argparse.Namespace,
169
+ hv: Hypervisor,
170
+ kill_switch: KillSwitch | None = None,
171
+ ) -> str:
172
+ """Dispatch a parsed session command to the appropriate handler."""
173
+ fmt = getattr(args, "output_format", "table")
174
+ cmd = args.session_command
175
+
176
+ if cmd == "list":
177
+ return cmd_list(hv, fmt)
178
+ elif cmd == "inspect":
179
+ return cmd_inspect(hv, args.session_id, fmt)
180
+ elif cmd == "kill":
181
+ return cmd_kill(hv, args.session_id, fmt, kill_switch)
182
+ else:
183
+ return "Error: specify a sub-command (list, inspect, kill)."
184
+
185
+
186
+ def main(argv: list[str] | None = None) -> None:
187
+ """Entry point for the CLI."""
188
+ top = argparse.ArgumentParser(prog="hypervisor")
189
+ sub = top.add_subparsers(dest="command")
190
+ build_parser(sub)
191
+
192
+ args = top.parse_args(argv)
193
+ if args.command != "session" or not args.session_command:
194
+ top.print_help()
195
+ sys.exit(1)
196
+
197
+ # In standalone mode, create an empty hypervisor (useful for testing the parser).
198
+ hv = Hypervisor()
199
+ output = dispatch(args, hv)
200
+ print(output)