Ghostgim commited on
Commit
666dbe8
·
verified ·
1 Parent(s): 97f7776

Tier 1: rich control panel — buttons, joystick, intervention, live trace

Browse files
Files changed (1) hide show
  1. app.py +420 -170
app.py CHANGED
@@ -1,26 +1,24 @@
1
- """HuggingFace Space — interactive ghostloop demo.
2
 
3
- Live URL: https://huggingface.co/spaces/Ghostgim/ghostloop-demo (after deploy).
4
 
5
- Lets a visitor:
6
- 1. Pick a robot profile (franka_arm / spot / tello / humanoid_demo).
7
- 2. See the safety pipeline (gates active for that profile).
8
- 3. Send an Intent and watch the runtime dispatch + record the trace.
9
- 4. Replay the trace as JSON.
10
- 5. Read each profile's instructions block to understand what the LLM
11
- "knows" about that robot.
12
 
13
- Why a Space: GitHub repos take 30 seconds to clone + install. A Space
14
- loads in 5 seconds, no install. That's where most reputation conversions
15
- happen the moment somebody clicks a link and sees Claude reasoning
16
- about a robot in the browser.
 
 
 
 
17
 
18
- Stack:
19
- gradio ≥ 4.0 (UI)
20
- ghostloop ≥ 1.0 (the library)
21
 
22
- Zero hardware deps runs against MockBackend so the Space starts
23
- instantly on the free CPU tier.
24
  """
25
 
26
  from __future__ import annotations
@@ -30,7 +28,14 @@ from typing import Any
30
 
31
  import gradio as gr
32
 
33
- from ghostloop import Intent
 
 
 
 
 
 
 
34
  from ghostloop.profiles import (
35
  build_runtime_from_profile,
36
  franka_arm,
@@ -51,200 +56,445 @@ PRESETS = {
51
  "turtlebot — wheeled mobile base": turtlebot_base,
52
  }
53
 
 
 
 
 
 
 
54
 
55
- def load_profile(profile_label: str) -> tuple[str, str, str, str]:
56
- """Build a runtime + return its description / primitives / gates / instructions."""
 
57
  factory = PRESETS[profile_label]
58
  profile = factory()
59
  runtime = build_runtime_from_profile(profile)
60
- primitives_md = "\n".join(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  f"- **`{name}`** — {runtime.registry.get(name).description}"
62
  for name in runtime.registry.names()
63
  )
64
- gates_md = "\n".join(
 
 
 
 
65
  f"- {g.__class__.__name__}" for g in runtime.policy_pipeline.gates
66
  )
67
- summary = (
68
- f"**Profile:** `{profile.name}` \n"
69
- f"**Morphology:** `{profile.morphology}` \n"
70
- f"**Backend:** `{runtime.backend.name}` (mock) \n"
71
- f"**Workspace:** `{profile.workspace_bounds}` \n"
72
- f"**Max velocity:** `{profile.max_velocity}` m/s \n"
73
- f"**HITL primitives:** `{profile.hitl_primitives}`"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  )
75
- return summary, primitives_md, gates_md, profile.instructions or "(no instructions block)"
76
 
77
 
78
- def step_runtime(profile_label: str, primitive_name: str, args_json: str) -> tuple[str, str]:
79
- """Dispatch one Intent against a fresh runtime, return result + last trace event."""
80
- factory = PRESETS[profile_label]
81
- runtime = build_runtime_from_profile(factory())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  try:
83
  args = json.loads(args_json) if args_json.strip() else {}
84
  if not isinstance(args, dict):
85
  raise ValueError("args must be a JSON object")
86
- except (ValueError, json.JSONDecodeError) as e:
87
- return f"❌ args JSON parse error: {e}", "—"
88
- if primitive_name not in runtime.registry.names():
89
- return (
90
- f"❌ Unknown primitive {primitive_name!r}. Available for "
91
- f"this profile: {runtime.registry.names()}",
92
- "—",
93
- )
94
- intent = Intent(name=primitive_name, args=args)
95
- result = runtime.step(intent)
96
- event = runtime.trace.events[-1]
97
- decision = event.decision
98
- status_icon = {"ok": "✅", "blocked": "🚫", "error": "❌"}.get(
99
- result.status.value, "⚠️"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  )
101
- summary = (
102
- f"{status_icon} **status:** `{result.status.value}` \n"
103
- f"**decision:** `{decision.action.value}`"
104
- f" (gate: `{decision.gate_name}`) \n"
105
- f"**reason:** {decision.reason} \n"
106
- f"**message:** {result.message}"
107
  )
108
- trace_json = json.dumps(event.to_json(), indent=2)
109
- return summary, trace_json
110
-
111
-
112
- def example_args(primitive_name: str) -> str:
113
- """Convenience: a sensible default args dict for common primitives."""
114
- examples = {
115
- "move_to": '{"x": 0.4, "y": 0.0, "z": 0.5}',
116
- "pick": '{"object_id": "widget-7"}',
117
- "place": '{}',
118
- "scan": '{"radius": 0.3}',
119
- "drive": '{"linear_x": 0.2, "angular_z": 0.0}',
120
- "stop": '{}',
121
- "goto": '{"x": 1.5, "y": -0.5, "theta": 0.0}',
122
- "rotate": '{"dtheta": 1.57}',
123
- "sit": '{}',
124
- "stand": '{}',
125
- "lie_down": '{}',
126
- "walk_to": '{"x": 2.0, "y": 1.0, "theta": 0.0}',
127
- "wave": '{"hand": "right"}',
128
- "look_at": '{"x": 1.0, "y": 0.0, "z": 1.5}',
129
- "point_at": '{"x": 1.0, "y": 0.0, "z": 1.5}',
130
- "nod": '{"direction": "yes"}',
131
- "takeoff": '{"altitude": 1.0}',
132
- "land": '{}',
133
- "fly_to": '{"x": 1.0, "y": 0.0, "z": 1.5, "yaw": 0.0}',
134
- "hover": '{"seconds": 2.0}',
135
- "set_joint": '{"joint_name": "shoulder", "angle": 0.5, "duration": 1.0}',
136
- "set_gripper": '{"state": "open", "force": 0.0}',
137
- "sense": '{"modality": "rgb"}',
138
- "scan_360": '{}',
139
- "take_photo": '{}',
140
- "read_battery": '{}',
141
- "wait": '{"seconds": 1.0}',
142
- "emit_event": '{"kind": "note", "message": "demo event"}',
143
- }
144
- return examples.get(primitive_name, "{}")
145
 
146
 
147
  with gr.Blocks(
148
- title="ghostloop — the agent loop, embodied",
149
  theme=gr.themes.Soft(primary_hue="teal"),
 
 
 
 
 
 
150
  ) as demo:
151
- gr.Markdown(
152
- """
153
- # ghostloop · live demo
154
 
155
- The agent loop, embodied. A tool-using runtime + fail-closed safety
156
- pipeline + sim-first execution + post-hoc analysis layer for embodied AI.
 
 
157
 
158
- This Space runs against `MockBackend` so it starts instantly. Every
159
- primitive call goes through the **same fail-closed safety pipeline** that
160
- ships in the library (geofence + force cap + action smoothing + rate
161
- limit + HITL) — try sending a `move_to` outside the workspace and watch
162
- the geofence reject it.
163
 
164
- [GitHub](https://github.com/joemunene-by/ghostloop) · [PyPI](https://pypi.org/project/ghostloop/) · [arXiv preprint](#) · [Sister project: GhostLM](https://github.com/joemunene-by/GhostLM)
165
- """
166
- )
167
 
 
168
  with gr.Row():
169
- with gr.Column(scale=1):
170
- profile_dd = gr.Dropdown(
171
- label="Robot profile",
172
- choices=list(PRESETS.keys()),
173
- value="franka_arm — 7-DOF arm",
174
- )
175
- summary_md = gr.Markdown(label="Profile summary")
176
- instructions_md = gr.Markdown(label="LLM instructions")
177
 
 
 
 
 
178
  with gr.Column(scale=1):
179
- primitives_md = gr.Markdown(label="Available primitives")
180
- gates_md = gr.Markdown(label="Active safety gates")
181
 
182
- profile_dd.change(
183
- load_profile,
184
- inputs=[profile_dd],
185
- outputs=[summary_md, primitives_md, gates_md, instructions_md],
186
- )
187
 
188
- gr.Markdown("## Dispatch a primitive")
 
 
 
 
 
 
 
 
189
 
 
 
190
  with gr.Row():
191
- primitive_in = gr.Textbox(
192
- label="Primitive name",
193
- value="move_to",
194
- placeholder="e.g. move_to / drive / takeoff / wave",
195
- )
196
- args_in = gr.Textbox(
197
- label="Args (JSON)",
198
- value='{"x": 0.4, "y": 0.0, "z": 0.5}',
199
- lines=2,
200
- )
201
- primitive_in.change(
202
- example_args, inputs=[primitive_in], outputs=[args_in],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  )
204
 
205
- run_btn = gr.Button("▶ runtime.step(intent)", variant="primary")
206
- result_md = gr.Markdown(label="Result")
207
- trace_json = gr.Code(label="Trace event (JSON)", language="json")
208
- run_btn.click(
209
- step_runtime,
210
- inputs=[profile_dd, primitive_in, args_in],
211
- outputs=[result_md, trace_json],
212
  )
213
-
214
- gr.Markdown(
215
- """
216
- ### Try these:
217
-
218
- 1. **Geofence violation:** keep `franka_arm` profile, send
219
- `move_to` with `{"x": 5.0, "y": 0.0, "z": 0.5}`. The Geofence gate
220
- blocks it; the trace records exactly why.
221
- 2. **HITL escalation:** switch to `tello`, send `takeoff` with
222
- `{"altitude": 1.0}`. The HumanInTheLoopGate is wired to `takeoff`
223
- for this profile — in a real terminal it'd prompt for approval; in
224
- the Space it returns an "approver declined" since there's no human.
225
- 3. **Cross-morphology:** switch to `spot`, send `walk_to` with
226
- `{"x": 2.0, "y": 1.0, "theta": 0.0}`. The same Runtime, the same
227
- safety pipeline pattern, completely different primitive set.
228
-
229
- Every call records a typed `TraceEvent` you can replay, diff, query
230
- with the trace DSL, score with the LLM judge, or attribute causally
231
- when something fails.
232
- """
233
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
- # Initial population.
236
- demo.load(
237
- load_profile,
238
- inputs=[profile_dd],
239
- outputs=[summary_md, primitives_md, gates_md, instructions_md],
240
  )
241
 
242
 
243
  if __name__ == "__main__":
244
- # HF Spaces require binding to 0.0.0.0:7860 explicitly. Without
245
- # server_name, gradio defaults to 127.0.0.1 and the Space proxy
246
- # can't reach the app — see the "localhost is not accessible"
247
- # error in earlier deploys.
248
  import os
249
  demo.launch(
250
  server_name="0.0.0.0",
 
1
+ """HuggingFace Space — ghostloop control panel.
2
 
3
+ Live URL: https://huggingface.co/spaces/Ghostgim/ghostloop-demo
4
 
5
+ A no-code interface to ghostloop. Visitors pick a robot profile, then
6
+ drive it via:
 
 
 
 
 
7
 
8
+ - Per-primitive dispatch buttons (auto-generated from the profile's
9
+ registry no JSON typing required).
10
+ - Virtual joystick (D-pad style) for mobile bases / quadrupeds /
11
+ drones.
12
+ - Free-form Intent dispatch (advanced — JSON args).
13
+ - Live intervention controls: Pause, Resume, Emergency Stop.
14
+ - Live trace pane that updates after every dispatch.
15
+ - Live state pane showing the backend snapshot.
16
 
17
+ The runtime is held per-session so the trace accumulates across
18
+ button clicks. Switch profiles to reset.
 
19
 
20
+ Backend is MockBackend so the Space runs on the free CPU tier without
21
+ any sim install.
22
  """
23
 
24
  from __future__ import annotations
 
28
 
29
  import gradio as gr
30
 
31
+ from ghostloop import (
32
+ Intent,
33
+ InterventionGate,
34
+ InterventionState,
35
+ LivePolicyController,
36
+ PolicyPipeline,
37
+ Runtime,
38
+ )
39
  from ghostloop.profiles import (
40
  build_runtime_from_profile,
41
  franka_arm,
 
56
  "turtlebot — wheeled mobile base": turtlebot_base,
57
  }
58
 
59
+ DEFAULT_PROFILE = "franka_arm — 7-DOF arm"
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Per-session runtime state (persisted via gr.State).
64
+ # ---------------------------------------------------------------------------
65
 
66
+
67
+ def make_session(profile_label: str) -> dict[str, Any]:
68
+ """Construct a fresh runtime session for a profile selection."""
69
  factory = PRESETS[profile_label]
70
  profile = factory()
71
  runtime = build_runtime_from_profile(profile)
72
+ controller = LivePolicyController(
73
+ policy=lambda state: Intent("emit_event", {"kind": "noop"}),
74
+ fallback_policy=lambda state: Intent(
75
+ "stop" if "stop" in runtime.registry.names() else "emit_event",
76
+ {"kind": "fallback"},
77
+ ),
78
+ )
79
+ # Add the InterventionGate to the front of the pipeline so pause
80
+ # affects future dispatches.
81
+ runtime.policy_pipeline = PolicyPipeline(
82
+ gates=[InterventionGate(controller=controller), *runtime.policy_pipeline.gates]
83
+ )
84
+ return {
85
+ "profile_label": profile_label,
86
+ "profile": profile,
87
+ "runtime": runtime,
88
+ "controller": controller,
89
+ }
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Default-args lookup so primitive buttons launch with sensible values.
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ DEFAULT_ARGS: dict[str, dict[str, Any]] = {
98
+ # Arm primitives.
99
+ "move_to": {"x": 0.4, "y": 0.0, "z": 0.5},
100
+ "scan": {"radius": 0.3},
101
+ "pick": {"object_id": "widget-7"},
102
+ "place": {},
103
+ "set_joint": {"joint_name": "shoulder", "angle": 0.5, "duration": 1.0},
104
+ "set_gripper": {"state": "open", "force": 0.0},
105
+ # Mobile base.
106
+ "drive": {"linear_x": 0.2, "angular_z": 0.0},
107
+ "stop": {},
108
+ "goto": {"x": 1.0, "y": 0.0, "theta": 0.0},
109
+ "rotate": {"dtheta": 1.57},
110
+ # Quadruped.
111
+ "sit": {},
112
+ "stand": {},
113
+ "lie_down": {},
114
+ "walk_to": {"x": 1.5, "y": 0.0, "theta": 0.0},
115
+ # Humanoid.
116
+ "wave": {"hand": "right"},
117
+ "look_at": {"x": 1.0, "y": 0.0, "z": 1.5},
118
+ "point_at": {"x": 1.0, "y": 0.0, "z": 1.5},
119
+ "nod": {"direction": "yes"},
120
+ # Aerial.
121
+ "takeoff": {"altitude": 1.0},
122
+ "land": {},
123
+ "fly_to": {"x": 1.0, "y": 0.0, "z": 1.5, "yaw": 0.0},
124
+ "hover": {"seconds": 2.0},
125
+ # Sensing.
126
+ "sense": {"modality": "rgb"},
127
+ "scan_360": {},
128
+ "take_photo": {},
129
+ "read_battery": {},
130
+ "wait": {"seconds": 1.0},
131
+ "emit_event": {"kind": "note", "message": "demo event"},
132
+ }
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Render helpers — keep all formatting in one place.
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def _render_profile_summary(session: dict[str, Any]) -> str:
141
+ profile = session["profile"]
142
+ runtime = session["runtime"]
143
+ return (
144
+ f"### `{profile.name}` — `{profile.morphology}`\n\n"
145
+ f"**Backend:** `{runtime.backend.name}` (mock) · "
146
+ f"**Workspace:** `{profile.workspace_bounds}` · "
147
+ f"**Max velocity:** `{profile.max_velocity}` m/s · "
148
+ f"**HITL primitives:** `{profile.hitl_primitives}`"
149
+ )
150
+
151
+
152
+ def _render_primitives_list(session: dict[str, Any]) -> str:
153
+ runtime = session["runtime"]
154
+ return "\n".join(
155
  f"- **`{name}`** — {runtime.registry.get(name).description}"
156
  for name in runtime.registry.names()
157
  )
158
+
159
+
160
+ def _render_gates_list(session: dict[str, Any]) -> str:
161
+ runtime = session["runtime"]
162
+ return "\n".join(
163
  f"- {g.__class__.__name__}" for g in runtime.policy_pipeline.gates
164
  )
165
+
166
+
167
+ def _render_state(session: dict[str, Any]) -> str:
168
+ runtime = session["runtime"]
169
+ return f"```json\n{json.dumps(runtime.backend.snapshot(), indent=2)}\n```"
170
+
171
+
172
+ def _render_trace(session: dict[str, Any]) -> str:
173
+ runtime = session["runtime"]
174
+ if not runtime.trace.events:
175
+ return "_(no events yet — dispatch a primitive to see the trace)_"
176
+ rows = ["| step | intent | decision | gate | result | reason |",
177
+ "|---:|---|:---:|---|:---:|---|"]
178
+ for ev in runtime.trace.events[-12:]:
179
+ decision_icon = {"allow": "✅", "deny": "🚫", "escalate": "⚠️"}.get(
180
+ ev.decision.action.value, "?"
181
+ )
182
+ result_icon = {"ok": "✅", "blocked": "🚫", "error": "❌"}.get(
183
+ ev.result.status.value, "?"
184
+ )
185
+ args_compact = json.dumps(ev.intent.args, separators=(",", ":"))
186
+ if len(args_compact) > 40:
187
+ args_compact = args_compact[:37] + "…"
188
+ reason = (ev.decision.reason or ev.result.message or "")[:80]
189
+ rows.append(
190
+ f"| {ev.step} | `{ev.intent.name}` {args_compact} | "
191
+ f"{decision_icon} | `{ev.decision.gate_name or ''}` | "
192
+ f"{result_icon} | {reason} |"
193
+ )
194
+ n = len(runtime.trace.events)
195
+ if n > 12:
196
+ rows.append(f"\n_showing last 12 of {n} events_")
197
+ return "\n".join(rows)
198
+
199
+
200
+ def _render_intervention_state(session: dict[str, Any]) -> str:
201
+ state = session["controller"].state
202
+ icon = {
203
+ InterventionState.RUNNING: "🟢",
204
+ InterventionState.PAUSED: "⏸️",
205
+ InterventionState.SWAPPING: "🔄",
206
+ InterventionState.EMERGENCY_STOP: "🛑",
207
+ }.get(state, "?")
208
+ return f"### {icon} Intervention: `{state.value}`"
209
+
210
+
211
+ def _render_instructions(session: dict[str, Any]) -> str:
212
+ return session["profile"].instructions or "(no instructions block)"
213
+
214
+
215
+ def _all_outputs(session: dict[str, Any]) -> tuple:
216
+ return (
217
+ _render_profile_summary(session),
218
+ _render_primitives_list(session),
219
+ _render_gates_list(session),
220
+ _render_instructions(session),
221
+ _render_state(session),
222
+ _render_trace(session),
223
+ _render_intervention_state(session),
224
  )
 
225
 
226
 
227
+ # ---------------------------------------------------------------------------
228
+ # Action handlers.
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def select_profile(profile_label: str):
233
+ session = make_session(profile_label)
234
+ names = session["runtime"].registry.names()
235
+ # Up to 12 primitive buttons; pad with empty updates for the rest.
236
+ btn_updates = []
237
+ for i in range(12):
238
+ if i < len(names):
239
+ btn_updates.append(gr.update(value=names[i], visible=True, interactive=True))
240
+ else:
241
+ btn_updates.append(gr.update(visible=False))
242
+ return (session, *_all_outputs(session), *btn_updates)
243
+
244
+
245
+ def dispatch_primitive(session: dict[str, Any], primitive_name: str):
246
+ if not primitive_name:
247
+ return session, *_all_outputs(session)
248
+ args = dict(DEFAULT_ARGS.get(primitive_name, {}))
249
+ session["runtime"].step(Intent(primitive_name, args))
250
+ return session, *_all_outputs(session)
251
+
252
+
253
+ def dispatch_custom(session: dict[str, Any], primitive_name: str, args_json: str):
254
+ if not primitive_name.strip():
255
+ return session, *_all_outputs(session)
256
  try:
257
  args = json.loads(args_json) if args_json.strip() else {}
258
  if not isinstance(args, dict):
259
  raise ValueError("args must be a JSON object")
260
+ except (ValueError, json.JSONDecodeError):
261
+ return session, *_all_outputs(session)
262
+ if primitive_name not in session["runtime"].registry.names():
263
+ return session, *_all_outputs(session)
264
+ session["runtime"].step(Intent(primitive_name, args))
265
+ return session, *_all_outputs(session)
266
+
267
+
268
+ def dispatch_drive(session: dict[str, Any], linear_x: float, angular_z: float):
269
+ """Joystick handler — emits drive(linear_x, angular_z) for mobile / quad."""
270
+ runtime = session["runtime"]
271
+ name = None
272
+ if "drive" in runtime.registry.names():
273
+ name = "drive"
274
+ args = {"linear_x": float(linear_x), "angular_z": float(angular_z)}
275
+ elif "fly_to" in runtime.registry.names():
276
+ # Drone: interpret as relative flight.
277
+ name = "fly_to"
278
+ args = {"x": float(linear_x), "y": 0.0, "z": 1.0, "yaw": float(angular_z)}
279
+ elif "walk_to" in runtime.registry.names():
280
+ name = "walk_to"
281
+ args = {"x": float(linear_x), "y": 0.0, "theta": float(angular_z)}
282
+ elif "move_to" in runtime.registry.names():
283
+ # Arm: interpret as a delta on x.
284
+ name = "move_to"
285
+ args = {"x": float(linear_x), "y": 0.0, "z": 0.5}
286
+ if name is None:
287
+ return session, *_all_outputs(session)
288
+ runtime.step(Intent(name, args))
289
+ return session, *_all_outputs(session)
290
+
291
+
292
+ def pause_runtime(session: dict[str, Any]):
293
+ session["controller"].pause(operator="ui_visitor", reason="UI pause button")
294
+ return session, *_all_outputs(session)
295
+
296
+
297
+ def resume_runtime(session: dict[str, Any]):
298
+ session["controller"].resume(operator="ui_visitor", reason="UI resume button")
299
+ return session, *_all_outputs(session)
300
+
301
+
302
+ def emergency_stop(session: dict[str, Any]):
303
+ runtime = session["runtime"]
304
+ stop_intent_name = (
305
+ "stop" if "stop" in runtime.registry.names() else
306
+ "land" if "land" in runtime.registry.names() else
307
+ "lie_down" if "lie_down" in runtime.registry.names() else
308
+ "emit_event"
309
  )
310
+ session["controller"].emergency_stop(
311
+ stop_intent=Intent(stop_intent_name, {}),
312
+ operator="ui_visitor", reason="UI E-STOP button",
 
 
 
313
  )
314
+ return session, *_all_outputs(session)
315
+
316
+
317
+ def clear_trace(session: dict[str, Any]):
318
+ session["runtime"].trace.events.clear()
319
+ return session, *_all_outputs(session)
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # UI.
324
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
 
327
  with gr.Blocks(
328
+ title="ghostloop — control panel",
329
  theme=gr.themes.Soft(primary_hue="teal"),
330
+ css="""
331
+ .gl-pad-button { min-height: 56px; font-weight: 600; }
332
+ .gl-estop button { background: #DC2626 !important; color: white !important; font-weight: 700; }
333
+ .gl-pause button { background: #F59E0B !important; color: white !important; }
334
+ .gl-resume button { background: #10B981 !important; color: white !important; }
335
+ """,
336
  ) as demo:
337
+ gr.Markdown("""
338
+ # ghostloop · control panel
 
339
 
340
+ Pick a robot profile, then drive it through the safety pipeline. Every
341
+ dispatch goes through `Geofence + ForceCap + ActionSmoothing + RateLimit
342
+ + HITL` — try sending a `move_to` outside the workspace and watch the
343
+ geofence reject it.
344
 
345
+ Sister to **[GhostLM](https://github.com/joemunene-by/GhostLM)** · `pip install ghostloop` · [GitHub](https://github.com/joemunene-by/ghostloop) · [PyPI](https://pypi.org/project/ghostloop/)
346
+ """)
 
 
 
347
 
348
+ session_state = gr.State()
 
 
349
 
350
+ # ---------- Profile picker ----------
351
  with gr.Row():
352
+ profile_dd = gr.Dropdown(
353
+ label="Robot profile",
354
+ choices=list(PRESETS.keys()),
355
+ value=DEFAULT_PROFILE,
356
+ scale=4,
357
+ )
358
+
359
+ summary_md = gr.Markdown()
360
 
361
+ with gr.Row():
362
+ with gr.Column(scale=1):
363
+ gr.Markdown("### Available primitives")
364
+ primitives_md = gr.Markdown()
365
  with gr.Column(scale=1):
366
+ gr.Markdown("### Active safety gates")
367
+ gates_md = gr.Markdown()
368
 
369
+ with gr.Accordion("Robot instructions (LLM system prompt)", open=False):
370
+ instructions_md = gr.Markdown()
 
 
 
371
 
372
+ # ---------- Quick-dispatch buttons ----------
373
+ gr.Markdown("## Dispatch a primitive (one click — uses sensible defaults)")
374
+ primitive_buttons: list[gr.Button] = []
375
+ with gr.Row():
376
+ for _ in range(6):
377
+ primitive_buttons.append(gr.Button("", variant="primary", elem_classes="gl-pad-button"))
378
+ with gr.Row():
379
+ for _ in range(6):
380
+ primitive_buttons.append(gr.Button("", variant="primary", elem_classes="gl-pad-button"))
381
 
382
+ # ---------- Joystick (D-pad) ----------
383
+ gr.Markdown("## Virtual joystick — drive / walk / fly / move")
384
  with gr.Row():
385
+ with gr.Column(scale=1):
386
+ pass
387
+ with gr.Column(scale=1):
388
+ joy_forward = gr.Button("⬆ FORWARD", elem_classes="gl-pad-button")
389
+ with gr.Column(scale=1):
390
+ pass
391
+ with gr.Row():
392
+ joy_left = gr.Button(" LEFT", elem_classes="gl-pad-button")
393
+ joy_stop = gr.Button("◼ STOP", elem_classes="gl-pad-button")
394
+ joy_right = gr.Button("RIGHT ➡", elem_classes="gl-pad-button")
395
+ with gr.Row():
396
+ with gr.Column(scale=1):
397
+ pass
398
+ with gr.Column(scale=1):
399
+ joy_back = gr.Button("⬇ BACK", elem_classes="gl-pad-button")
400
+ with gr.Column(scale=1):
401
+ pass
402
+
403
+ # ---------- Intervention controls ----------
404
+ gr.Markdown("## Live intervention")
405
+ intervention_md = gr.Markdown()
406
+ with gr.Row():
407
+ pause_btn = gr.Button("⏸ Pause", elem_classes="gl-pause")
408
+ resume_btn = gr.Button("▶ Resume", elem_classes="gl-resume")
409
+ estop_btn = gr.Button("🛑 EMERGENCY STOP", elem_classes="gl-estop")
410
+
411
+ # ---------- Live trace + state ----------
412
+ with gr.Row():
413
+ with gr.Column(scale=2):
414
+ gr.Markdown("## Live trace (most recent first)")
415
+ trace_md = gr.Markdown()
416
+ clear_trace_btn = gr.Button("Clear trace")
417
+ with gr.Column(scale=1):
418
+ gr.Markdown("## Current backend state")
419
+ state_md = gr.Markdown()
420
+
421
+ # ---------- Advanced: free-form Intent ----------
422
+ with gr.Accordion("Advanced: free-form Intent (JSON args)", open=False):
423
+ with gr.Row():
424
+ adv_name = gr.Textbox(label="Primitive", value="move_to", scale=1)
425
+ adv_args = gr.Textbox(
426
+ label="args JSON",
427
+ value='{"x": 0.4, "y": 0.0, "z": 0.5}',
428
+ lines=2, scale=3,
429
+ )
430
+ adv_btn = gr.Button("▶ runtime.step(intent)", variant="secondary")
431
+
432
+ # ---------- Try-this hints ----------
433
+ gr.Markdown("""
434
+ ### Try this:
435
+
436
+ 1. Pick the **`franka_arm`** profile, then click **`move_to`** — it dispatches with `(0.4, 0, 0.5)` and lands inside the workspace ✅. Now expand the **Advanced** accordion and dispatch `move_to` with `{"x": 5.0, "y": 0, "z": 0}` — the GeofenceGate rejects it 🚫.
437
+ 2. Switch to **`spot`**, then drive with the joystick. Each press emits `walk_to(linear_x, 0, angular_z)` through the safety pipeline.
438
+ 3. Hit **🛑 EMERGENCY STOP**. Try clicking another primitive — it gets denied. Hit **▶ Resume** to recover.
439
+
440
+ Every dispatch is recorded in the trace pane below — that's the same `TraceEvent` shape the library exports for replay, diff, query, energy ledger, and LLM-judge scoring.
441
+ """)
442
+
443
+ # ---------- Wiring ----------
444
+ profile_outputs = [
445
+ session_state,
446
+ summary_md, primitives_md, gates_md, instructions_md,
447
+ state_md, trace_md, intervention_md,
448
+ *primitive_buttons,
449
+ ]
450
+ standard_outputs = [
451
+ session_state,
452
+ summary_md, primitives_md, gates_md, instructions_md,
453
+ state_md, trace_md, intervention_md,
454
+ ]
455
+
456
+ profile_dd.change(select_profile, inputs=[profile_dd], outputs=profile_outputs)
457
+ demo.load(select_profile, inputs=[profile_dd], outputs=profile_outputs)
458
+
459
+ for btn in primitive_buttons:
460
+ btn.click(
461
+ dispatch_primitive, inputs=[session_state, btn], outputs=standard_outputs,
462
  )
463
 
464
+ joy_forward.click(
465
+ lambda s: dispatch_drive(s, 0.2, 0.0),
466
+ inputs=[session_state], outputs=standard_outputs,
 
 
 
 
467
  )
468
+ joy_back.click(
469
+ lambda s: dispatch_drive(s, -0.2, 0.0),
470
+ inputs=[session_state], outputs=standard_outputs,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  )
472
+ joy_left.click(
473
+ lambda s: dispatch_drive(s, 0.0, 0.5),
474
+ inputs=[session_state], outputs=standard_outputs,
475
+ )
476
+ joy_right.click(
477
+ lambda s: dispatch_drive(s, 0.0, -0.5),
478
+ inputs=[session_state], outputs=standard_outputs,
479
+ )
480
+ joy_stop.click(
481
+ lambda s: dispatch_drive(s, 0.0, 0.0),
482
+ inputs=[session_state], outputs=standard_outputs,
483
+ )
484
+
485
+ pause_btn.click(pause_runtime, inputs=[session_state], outputs=standard_outputs)
486
+ resume_btn.click(resume_runtime, inputs=[session_state], outputs=standard_outputs)
487
+ estop_btn.click(emergency_stop, inputs=[session_state], outputs=standard_outputs)
488
+ clear_trace_btn.click(clear_trace, inputs=[session_state], outputs=standard_outputs)
489
 
490
+ adv_btn.click(
491
+ dispatch_custom,
492
+ inputs=[session_state, adv_name, adv_args],
493
+ outputs=standard_outputs,
 
494
  )
495
 
496
 
497
  if __name__ == "__main__":
 
 
 
 
498
  import os
499
  demo.launch(
500
  server_name="0.0.0.0",