A monorepo management tool for the agentic ages

Add live web UI for Claude agent with WebSocket streaming

New --web PORT option enables a real-time dashboard that shows:
- Agent thinking/activity status
- Tool calls with input/output
- Text responses as they stream
- Errors and sync events
- Turn costs and running total

Architecture:
- event.ml: Event types and pub/sub bus for broadcasting
- web.ml: Minimal WebSocket server with inline HTML/CSS/JS
- SHA1/Base64 for WebSocket handshake (digestif, base64)
- Each WS connection subscribes directly to event bus
- Dark theme, monospace font, auto-reconnect

Usage:
unpac-claude --web 8080 # Interactive with web UI
unpac-claude -a --web 8080 -w /path # Autonomous with web UI

The UI auto-scrolls to show latest events and displays:
- Tool names with syntax-highlighted JSON input
- Collapsible tool output (truncated for large results)
- Turn count and cumulative cost in footer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+736 -11
+21 -3
bin/unpac-claude/main.ml
··· 8 8 Logs.set_level (Some level); 9 9 Logs.set_reporter (Logs_fmt.reporter ()) 10 10 11 - let run_agent model max_turns verbose autonomous sync_interval workspace_path prompt_opt = 11 + let run_agent model max_turns verbose autonomous sync_interval web_port workspace_path prompt_opt = 12 12 setup_logging verbose; 13 13 Eio_main.run @@ fun env -> 14 14 let model = match model with ··· 23 23 verbose; 24 24 autonomous; 25 25 sync_interval; 26 + web_port; 26 27 } in 27 28 Unpac_claude.Agent.run ~env ~config ~initial_prompt:prompt_opt ?workspace_path () 28 29 ··· 49 50 let doc = "In autonomous mode, run unpac status and push every N turns." in 50 51 Arg.(value & opt int 10 & info ["sync-interval"] ~docv:"N" ~doc) 51 52 53 + let web_port_arg = 54 + let doc = "Enable web UI on this port. Opens a live dashboard showing agent \ 55 + events, tool calls, and responses in real-time via WebSocket." in 56 + Arg.(value & opt (some int) None & info ["web"] ~docv:"PORT" ~doc) 57 + 52 58 let workspace_arg = 53 59 let doc = "Path to the unpac workspace to analyze. If not specified, searches \ 54 60 upward from current directory." in ··· 71 77 `I ("Autonomous (-a)", "Continuously analyzes all projects, updates STATUS.md \ 72 78 files, refactors code, and syncs with remote. Runs \ 73 79 until max-turns or manual interruption."); 80 + `S "WEB UI"; 81 + `P "Use --web PORT to enable a live web dashboard that shows:"; 82 + `I ("Events", "Real-time streaming of agent activity"); 83 + `I ("Tool calls", "Each tool invocation with input/output"); 84 + `I ("Text output", "Assistant responses as they stream in"); 85 + `I ("Costs", "Running total of API costs"); 86 + `P "The web UI connects via WebSocket and auto-reconnects if disconnected."; 74 87 `S "AUTONOMOUS ANALYSIS"; 75 88 `P "In autonomous mode, the agent will:"; 76 89 `I ("1. Project Analysis", "Read STATUS.md, scan source files, check tests"); ··· 85 98 `S Manpage.s_examples; 86 99 `P "Start interactive mode:"; 87 100 `Pre " unpac-claude"; 101 + `P "Start with web UI on port 8080:"; 102 + `Pre " unpac-claude --web 8080"; 103 + `P "Autonomous mode with web UI:"; 104 + `Pre " unpac-claude -a --web 8080 -w /path/to/workspace"; 88 105 `P "Start autonomous analysis of a workspace:"; 89 106 `Pre " unpac-claude -a -w /path/to/workspace"; 90 107 `P "Autonomous with custom sync interval:"; ··· 116 133 `P "The agent exits with 0 on normal completion (max-turns reached or \ 117 134 user quit). It can be interrupted with Ctrl+C."; 118 135 ] in 119 - let info = Cmd.info "unpac-claude" ~version:"0.2.0" ~doc ~man in 136 + let info = Cmd.info "unpac-claude" ~version:"0.3.0" ~doc ~man in 120 137 Cmd.v info Term.(const run_agent $ model_arg $ max_turns_arg $ verbose_arg $ 121 - autonomous_arg $ sync_interval_arg $ workspace_arg $ prompt_arg) 138 + autonomous_arg $ sync_interval_arg $ web_port_arg $ 139 + workspace_arg $ prompt_arg) 122 140 123 141 let () = exit (Cmd.eval cmd)
+3 -1
dune-project
··· 44 44 (eio_main (>= 1.0)) 45 45 (cmdliner (>= 1.2.0)) 46 46 (logs (>= 0.7.0)) 47 - (fmt (>= 0.9.0)))) 47 + (fmt (>= 0.9.0)) 48 + (digestif (>= 1.0.0)) 49 + (base64 (>= 3.0.0))))
+86 -6
lib/claude/agent.ml
··· 13 13 verbose : bool; 14 14 autonomous : bool; 15 15 sync_interval : int; (* turns between unpac status/push *) 16 + web_port : int option; (* port for web UI, None = disabled *) 16 17 } 17 18 18 19 let default_config = { ··· 21 22 verbose = false; 22 23 autonomous = false; 23 24 sync_interval = 10; 25 + web_port = None; 24 26 } 25 27 26 28 (* Rate limit state *) ··· 106 108 check 0 107 109 end 108 110 111 + (* Simple JSON to string serialization *) 112 + let rec json_to_string (json : Jsont.json) = 113 + let escape_string s = 114 + let buf = Buffer.create (String.length s + 16) in 115 + Buffer.add_char buf '"'; 116 + String.iter (fun c -> 117 + match c with 118 + | '"' -> Buffer.add_string buf "\\\"" 119 + | '\\' -> Buffer.add_string buf "\\\\" 120 + | '\n' -> Buffer.add_string buf "\\n" 121 + | '\r' -> Buffer.add_string buf "\\r" 122 + | '\t' -> Buffer.add_string buf "\\t" 123 + | c when Char.code c < 32 -> 124 + Buffer.add_string buf (Printf.sprintf "\\u%04x" (Char.code c)) 125 + | c -> Buffer.add_char buf c 126 + ) s; 127 + Buffer.add_char buf '"'; 128 + Buffer.contents buf 129 + in 130 + match json with 131 + | Jsont.Null _ -> "null" 132 + | Jsont.Bool (b, _) -> if b then "true" else "false" 133 + | Jsont.Number (n, _) -> 134 + if Float.is_integer n then Printf.sprintf "%.0f" n 135 + else Printf.sprintf "%g" n 136 + | Jsont.String (s, _) -> escape_string s 137 + | Jsont.Array (items, _) -> 138 + "[" ^ String.concat "," (List.map json_to_string items) ^ "]" 139 + | Jsont.Object (members, _) -> 140 + let member_to_string ((key, _), value) = 141 + escape_string key ^ ":" ^ json_to_string value 142 + in 143 + "{" ^ String.concat "," (List.map member_to_string members) ^ "}" 144 + 109 145 let is_rate_limit_error msg = 110 146 let s = String.lowercase_ascii msg in 111 147 string_contains ~sub:"rate" s || ··· 119 155 let fs = Eio.Stdenv.fs env in 120 156 let proc_mgr = Eio.Stdenv.process_mgr env in 121 157 let clock = Eio.Stdenv.clock env in 158 + let net = Eio.Stdenv.net env in 122 159 123 160 (* Find unpac root *) 124 161 let root = match workspace_path with ··· 141 178 if config.autonomous then 142 179 Log.info (fun m -> m "Running in AUTONOMOUS mode"); 143 180 181 + (* Create event bus *) 182 + let event_bus = Event.create_bus () in 183 + 184 + (* Helper to emit events *) 185 + let emit event = Event.emit event_bus event in 186 + 187 + (* Run main agent logic in a switch *) 188 + Eio.Switch.run @@ fun sw -> 189 + 190 + (* Start web server if enabled *) 191 + (match config.web_port with 192 + | Some port -> 193 + let _web = Web.start ~sw ~net ~port event_bus in 194 + Log.info (fun m -> m "Web UI available at http://localhost:%d" port); 195 + Format.printf "Web UI: http://localhost:%d@." port 196 + | None -> ()); 197 + 144 198 (* Generate system prompt *) 145 199 let system_prompt = Prompt.generate ~proc_mgr ~root ~autonomous:config.autonomous in 146 200 Log.debug (fun m -> m "System prompt length: %d" (String.length system_prompt)); ··· 158 212 (* Rate limit state *) 159 213 let rate_state = create_rate_limit_state () in 160 214 215 + (* Emit agent start *) 216 + emit Event.Agent_start; 217 + 161 218 (* Main loop *) 162 219 let rec loop turn_count last_sync prompt = 163 220 (* Check turn limit *) 164 221 match config.max_turns with 165 222 | Some max when turn_count >= max -> 166 - Format.printf "@.Reached maximum turns (%d). Exiting.@." max 223 + Format.printf "@.Reached maximum turns (%d). Exiting.@." max; 224 + emit Event.Agent_stop 167 225 | _ -> 168 226 (* Check rate limit *) 169 227 (match should_wait_for_rate_limit ~clock rate_state with ··· 179 237 let (prompt, last_sync) = 180 238 if should_sync then begin 181 239 Log.info (fun m -> m "Periodic sync: running unpac status and push"); 240 + emit (Event.Sync "status"); 182 241 ("First run unpac_status_sync to update the workspace status, then run \ 183 242 unpac_push with remote='origin' to sync changes. After that, continue \ 184 243 analyzing and improving the codebase.", turn_count) ··· 189 248 (* Create client and send message *) 190 249 let continue = ref true in 191 250 let error_occurred = ref false in 251 + let turn_cost = ref None in 192 252 begin 193 253 try 194 254 Eio.Switch.run @@ fun sw -> 195 255 let client = Claude.Client.create ~sw ~process_mgr:proc_mgr ~clock ~options () in 196 256 257 + (* Emit thinking event *) 258 + emit Event.Thinking; 259 + 197 260 (* Create response handler that handles tool calls *) 198 261 let handler = object 199 262 inherit Claude.Handler.default 200 263 201 264 method! on_text text = 202 - print_string (Claude.Response.Text.content text); 203 - flush stdout 265 + let content = Claude.Response.Text.content text in 266 + print_string content; 267 + flush stdout; 268 + emit (Event.Text content) 204 269 205 270 method! on_tool_use tool = 206 271 let name = Claude.Response.Tool_use.name tool in ··· 212 277 213 278 (* Convert Tool_input.t to Jsont.json *) 214 279 let json_input = Claude.Tool_input.to_json input in 280 + let input_str = json_to_string json_input in 281 + 282 + (* Emit tool call event *) 283 + emit (Event.Tool_call { id; name; input = input_str }); 215 284 216 285 (* Execute the tool *) 217 286 let result = Tools.execute ~proc_mgr ~fs ~root ~tool_name:name ~input:json_input in ··· 220 289 | Tools.Error e -> (true, e) 221 290 in 222 291 292 + (* Emit tool result event *) 293 + emit (Event.Tool_result { id; name; output = content; is_error }); 294 + 223 295 if config.verbose then 224 296 Log.info (fun m -> m "Tool result: %s..." 225 297 (String.sub content 0 (min 100 (String.length content)))); ··· 232 304 method! on_complete result = 233 305 print_newline (); 234 306 record_success rate_state; 307 + let cost = Claude.Response.Complete.total_cost_usd result in 308 + turn_cost := cost; 235 309 if config.verbose then begin 236 - let cost = Claude.Response.Complete.total_cost_usd result in 237 310 match cost with 238 311 | Some c -> Log.info (fun m -> m "Turn complete, cost: $%.4f" c) 239 312 | None -> Log.info (fun m -> m "Turn complete") ··· 242 315 method! on_error err = 243 316 let msg = Claude.Response.Error.message err in 244 317 Log.err (fun m -> m "Claude error: %s" msg); 318 + emit (Event.Error msg); 245 319 if is_rate_limit_error msg then begin 246 320 let delay = record_rate_limit_error ~clock rate_state in 247 321 Format.eprintf "Rate limit hit, will retry in %.0f seconds@." delay ··· 255 329 with exn -> 256 330 let msg = Printexc.to_string exn in 257 331 Log.err (fun m -> m "Exception: %s" msg); 332 + emit (Event.Error msg); 258 333 if is_rate_limit_error msg then begin 259 334 let delay = record_rate_limit_error ~clock rate_state in 260 335 Format.eprintf "Rate limit exception, will retry in %.0f seconds@." delay 261 336 end; 262 337 error_occurred := true 263 338 end; 339 + 340 + (* Emit turn complete *) 341 + emit (Event.Turn_complete { turn = turn_count; cost_usd = !turn_cost }); 264 342 265 343 (* Handle next iteration *) 266 344 if !error_occurred && config.autonomous then begin ··· 290 368 in 291 369 292 370 match next_prompt with 293 - | None -> () 371 + | None -> emit Event.Agent_stop 294 372 | Some "" -> loop (turn_count + 1) last_sync "Continue with the current task." 295 - | Some "exit" | Some "quit" -> Format.printf "Goodbye!@." 373 + | Some "exit" | Some "quit" -> 374 + emit Event.Agent_stop; 375 + Format.printf "Goodbye!@." 296 376 | Some p -> loop (turn_count + 1) last_sync p 297 377 end 298 378 in
+1
lib/claude/agent.mli
··· 13 13 verbose : bool; 14 14 autonomous : bool; (** Autonomous mode - continuous analysis *) 15 15 sync_interval : int; (** Turns between unpac status/push in autonomous mode *) 16 + web_port : int option; (** Port for web UI, None = disabled *) 16 17 } 17 18 18 19 val default_config : config
+1 -1
lib/claude/dune
··· 1 1 (library 2 2 (name unpac_claude) 3 3 (public_name unpac-claude) 4 - (libraries unpac claude eio eio_main logs fmt jsont)) 4 + (libraries unpac claude eio eio_main logs fmt jsont digestif base64))
+96
lib/claude/event.ml
··· 1 + (** Event types emitted by the Claude agent for live UI updates. *) 2 + 3 + type tool_call = { 4 + id : string; 5 + name : string; 6 + input : string; (* JSON string *) 7 + } 8 + 9 + type tool_result = { 10 + id : string; 11 + name : string; 12 + output : string; 13 + is_error : bool; 14 + } 15 + 16 + type t = 17 + | Thinking 18 + | Text of string 19 + | Tool_call of tool_call 20 + | Tool_result of tool_result 21 + | Error of string 22 + | Sync of string (* "status" or "push" *) 23 + | Turn_complete of { turn : int; cost_usd : float option } 24 + | Agent_start 25 + | Agent_stop 26 + 27 + (* Simple JSON string escaping *) 28 + let escape_json_string s = 29 + let buf = Buffer.create (String.length s + 16) in 30 + Buffer.add_char buf '"'; 31 + String.iter (fun c -> 32 + match c with 33 + | '"' -> Buffer.add_string buf "\\\"" 34 + | '\\' -> Buffer.add_string buf "\\\\" 35 + | '\n' -> Buffer.add_string buf "\\n" 36 + | '\r' -> Buffer.add_string buf "\\r" 37 + | '\t' -> Buffer.add_string buf "\\t" 38 + | c when Char.code c < 32 -> 39 + Buffer.add_string buf (Printf.sprintf "\\u%04x" (Char.code c)) 40 + | c -> Buffer.add_char buf c 41 + ) s; 42 + Buffer.add_char buf '"'; 43 + Buffer.contents buf 44 + 45 + let to_json = function 46 + | Thinking -> 47 + {|{"type":"thinking"}|} 48 + | Text s -> 49 + Printf.sprintf {|{"type":"text","content":%s}|} 50 + (escape_json_string s) 51 + | Tool_call { id; name; input } -> 52 + Printf.sprintf {|{"type":"tool_call","id":%s,"name":%s,"input":%s}|} 53 + (escape_json_string id) 54 + (escape_json_string name) 55 + input (* input is already JSON *) 56 + | Tool_result { id; name; output; is_error } -> 57 + Printf.sprintf {|{"type":"tool_result","id":%s,"name":%s,"output":%s,"is_error":%b}|} 58 + (escape_json_string id) 59 + (escape_json_string name) 60 + (escape_json_string output) 61 + is_error 62 + | Error msg -> 63 + Printf.sprintf {|{"type":"error","message":%s}|} 64 + (escape_json_string msg) 65 + | Sync action -> 66 + Printf.sprintf {|{"type":"sync","action":%s}|} 67 + (escape_json_string action) 68 + | Turn_complete { turn; cost_usd } -> 69 + let cost_str = match cost_usd with 70 + | Some c -> Printf.sprintf "%.6f" c 71 + | None -> "null" 72 + in 73 + Printf.sprintf {|{"type":"turn_complete","turn":%d,"cost_usd":%s}|} 74 + turn cost_str 75 + | Agent_start -> 76 + {|{"type":"agent_start"}|} 77 + | Agent_stop -> 78 + {|{"type":"agent_stop"}|} 79 + 80 + (* Event bus for broadcasting to listeners *) 81 + type listener = t -> unit 82 + 83 + type bus = { 84 + mutable listeners : listener list; 85 + } 86 + 87 + let create_bus () = { listeners = [] } 88 + 89 + let subscribe bus listener = 90 + bus.listeners <- listener :: bus.listeners 91 + 92 + let unsubscribe bus listener = 93 + bus.listeners <- List.filter (fun l -> l != listener) bus.listeners 94 + 95 + let emit bus event = 96 + List.iter (fun l -> l event) bus.listeners
+41
lib/claude/event.mli
··· 1 + (** Event types for live agent UI updates. *) 2 + 3 + (** Tool call event data. *) 4 + type tool_call = { 5 + id : string; 6 + name : string; 7 + input : string; 8 + } 9 + 10 + (** Tool result event data. *) 11 + type tool_result = { 12 + id : string; 13 + name : string; 14 + output : string; 15 + is_error : bool; 16 + } 17 + 18 + (** Agent events. *) 19 + type t = 20 + | Thinking 21 + | Text of string 22 + | Tool_call of tool_call 23 + | Tool_result of tool_result 24 + | Error of string 25 + | Sync of string 26 + | Turn_complete of { turn : int; cost_usd : float option } 27 + | Agent_start 28 + | Agent_stop 29 + 30 + val to_json : t -> string 31 + (** Convert event to JSON string. *) 32 + 33 + (** Event bus for broadcasting to listeners. *) 34 + 35 + type listener = t -> unit 36 + type bus 37 + 38 + val create_bus : unit -> bus 39 + val subscribe : bus -> listener -> unit 40 + val unsubscribe : bus -> listener -> unit 41 + val emit : bus -> t -> unit
+2
lib/claude/unpac_claude.ml
··· 3 3 module Tools = Tools 4 4 module Prompt = Prompt 5 5 module Agent = Agent 6 + module Event = Event 7 + module Web = Web
+460
lib/claude/web.ml
··· 1 + (** Minimal WebSocket server for live agent UI. 2 + 3 + Uses cohttp-eio for the HTTP upgrade handshake, 4 + then raw Eio sockets for WebSocket frames. *) 5 + 6 + let src = Logs.Src.create "unpac.claude.web" ~doc:"Web server" 7 + module Log = (val Logs.src_log src : Logs.LOG) 8 + 9 + (* WebSocket frame helpers *) 10 + module Ws = struct 11 + (* Compute Sec-WebSocket-Accept from client key *) 12 + let accept_key client_key = 13 + let magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" in 14 + let combined = client_key ^ magic in 15 + let hash = Digestif.SHA1.digest_string combined in 16 + Base64.encode_exn (Digestif.SHA1.to_raw_string hash) 17 + 18 + (* Send a WebSocket text frame *) 19 + let send_text flow text = 20 + let len = String.length text in 21 + let header = 22 + if len < 126 then 23 + let h = Bytes.create 2 in 24 + Bytes.set_uint8 h 0 0x81; (* FIN + text opcode *) 25 + Bytes.set_uint8 h 1 len; 26 + Bytes.to_string h 27 + else if len < 65536 then 28 + let h = Bytes.create 4 in 29 + Bytes.set_uint8 h 0 0x81; 30 + Bytes.set_uint8 h 1 126; 31 + Bytes.set_uint16_be h 2 len; 32 + Bytes.to_string h 33 + else 34 + let h = Bytes.create 10 in 35 + Bytes.set_uint8 h 0 0x81; 36 + Bytes.set_uint8 h 1 127; 37 + Bytes.set_int64_be h 2 (Int64.of_int len); 38 + Bytes.to_string h 39 + in 40 + Eio.Flow.copy_string (header ^ text) flow 41 + 42 + (* Send close frame *) 43 + let send_close flow = 44 + let frame = Bytes.create 2 in 45 + Bytes.set_uint8 frame 0 0x88; (* FIN + close opcode *) 46 + Bytes.set_uint8 frame 1 0; 47 + Eio.Flow.copy_string (Bytes.to_string frame) flow 48 + 49 + (* Read a WebSocket frame, returns (opcode, payload) or None on close *) 50 + let read_frame flow = 51 + let buf = Cstruct.create 2 in 52 + match Eio.Flow.read_exact flow buf with 53 + | exception End_of_file -> None 54 + | () -> 55 + let b0 = Cstruct.get_uint8 buf 0 in 56 + let b1 = Cstruct.get_uint8 buf 1 in 57 + let _fin = (b0 land 0x80) <> 0 in 58 + let opcode = b0 land 0x0F in 59 + let masked = (b1 land 0x80) <> 0 in 60 + let len0 = b1 land 0x7F in 61 + 62 + (* Get actual length *) 63 + let len = 64 + if len0 < 126 then len0 65 + else if len0 = 126 then begin 66 + let buf = Cstruct.create 2 in 67 + Eio.Flow.read_exact flow buf; 68 + Cstruct.BE.get_uint16 buf 0 69 + end else begin 70 + let buf = Cstruct.create 8 in 71 + Eio.Flow.read_exact flow buf; 72 + Int64.to_int (Cstruct.BE.get_uint64 buf 0) 73 + end 74 + in 75 + 76 + (* Get mask if present *) 77 + let mask = 78 + if masked then begin 79 + let buf = Cstruct.create 4 in 80 + Eio.Flow.read_exact flow buf; 81 + Some (Cstruct.to_bytes buf) 82 + end else None 83 + in 84 + 85 + (* Read payload *) 86 + let payload = Cstruct.create len in 87 + if len > 0 then Eio.Flow.read_exact flow payload; 88 + 89 + (* Unmask if needed *) 90 + let data = 91 + match mask with 92 + | None -> Cstruct.to_string payload 93 + | Some m -> 94 + let bytes = Cstruct.to_bytes payload in 95 + for i = 0 to len - 1 do 96 + let b = Bytes.get_uint8 bytes i in 97 + let k = Bytes.get_uint8 m (i mod 4) in 98 + Bytes.set_uint8 bytes i (b lxor k) 99 + done; 100 + Bytes.to_string bytes 101 + in 102 + Some (opcode, data) 103 + end 104 + 105 + (* Static HTML page *) 106 + let index_html = {|<!DOCTYPE html> 107 + <html lang="en"> 108 + <head> 109 + <meta charset="UTF-8"> 110 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 111 + <title>unpac-claude</title> 112 + <style> 113 + :root { 114 + --bg: #0d1117; 115 + --fg: #c9d1d9; 116 + --dim: #6e7681; 117 + --border: #30363d; 118 + --accent: #58a6ff; 119 + --green: #3fb950; 120 + --red: #f85149; 121 + --yellow: #d29922; 122 + } 123 + * { box-sizing: border-box; margin: 0; padding: 0; } 124 + body { 125 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; 126 + font-size: 14px; 127 + line-height: 1.5; 128 + background: var(--bg); 129 + color: var(--fg); 130 + height: 100vh; 131 + display: flex; 132 + flex-direction: column; 133 + } 134 + header { 135 + padding: 12px 16px; 136 + border-bottom: 1px solid var(--border); 137 + display: flex; 138 + align-items: center; 139 + gap: 12px; 140 + } 141 + header h1 { 142 + font-size: 16px; 143 + font-weight: 600; 144 + } 145 + .status { 146 + font-size: 12px; 147 + padding: 2px 8px; 148 + border-radius: 12px; 149 + background: var(--border); 150 + } 151 + .status.connected { background: var(--green); color: #000; } 152 + .status.error { background: var(--red); color: #fff; } 153 + #events { 154 + flex: 1; 155 + overflow-y: auto; 156 + padding: 16px; 157 + } 158 + .event { 159 + margin-bottom: 8px; 160 + padding: 8px 12px; 161 + border-radius: 6px; 162 + border-left: 3px solid var(--border); 163 + } 164 + .event.thinking { border-color: var(--yellow); color: var(--dim); } 165 + .event.text { border-color: var(--fg); white-space: pre-wrap; } 166 + .event.tool_call { border-color: var(--accent); } 167 + .event.tool_result { border-color: var(--green); } 168 + .event.tool_result.error { border-color: var(--red); } 169 + .event.error { border-color: var(--red); background: rgba(248,81,73,0.1); } 170 + .event.sync { border-color: var(--yellow); } 171 + .event.turn_complete { border-color: var(--dim); color: var(--dim); font-size: 12px; } 172 + .tool-name { 173 + color: var(--accent); 174 + font-weight: 600; 175 + } 176 + .tool-input, .tool-output { 177 + margin-top: 4px; 178 + padding: 8px; 179 + background: rgba(0,0,0,0.3); 180 + border-radius: 4px; 181 + font-size: 12px; 182 + max-height: 200px; 183 + overflow: auto; 184 + white-space: pre-wrap; 185 + word-break: break-all; 186 + } 187 + .cost { color: var(--dim); } 188 + footer { 189 + padding: 8px 16px; 190 + border-top: 1px solid var(--border); 191 + font-size: 12px; 192 + color: var(--dim); 193 + } 194 + </style> 195 + </head> 196 + <body> 197 + <header> 198 + <h1>unpac-claude</h1> 199 + <span id="status" class="status">connecting...</span> 200 + </header> 201 + <div id="events"></div> 202 + <footer> 203 + <span id="turn">Turn: 0</span> | 204 + <span id="cost">Cost: $0.00</span> 205 + </footer> 206 + <script> 207 + const events = document.getElementById('events'); 208 + const status = document.getElementById('status'); 209 + const turnEl = document.getElementById('turn'); 210 + const costEl = document.getElementById('cost'); 211 + let totalCost = 0; 212 + let currentTurn = 0; 213 + 214 + function connect() { 215 + const ws = new WebSocket(`ws://${location.host}/ws`); 216 + 217 + ws.onopen = () => { 218 + status.textContent = 'connected'; 219 + status.className = 'status connected'; 220 + }; 221 + 222 + ws.onclose = () => { 223 + status.textContent = 'disconnected'; 224 + status.className = 'status'; 225 + setTimeout(connect, 2000); 226 + }; 227 + 228 + ws.onerror = () => { 229 + status.textContent = 'error'; 230 + status.className = 'status error'; 231 + }; 232 + 233 + ws.onmessage = (e) => { 234 + const data = JSON.parse(e.data); 235 + handleEvent(data); 236 + }; 237 + } 238 + 239 + function handleEvent(e) { 240 + const div = document.createElement('div'); 241 + div.className = 'event ' + e.type; 242 + 243 + switch (e.type) { 244 + case 'thinking': 245 + div.textContent = '⋯ thinking...'; 246 + break; 247 + case 'text': 248 + div.textContent = e.content; 249 + break; 250 + case 'tool_call': 251 + div.innerHTML = `<span class="tool-name">${esc(e.name)}</span>` + 252 + `<div class="tool-input">${esc(formatJson(e.input))}</div>`; 253 + break; 254 + case 'tool_result': 255 + if (e.is_error) div.classList.add('error'); 256 + div.innerHTML = `<span class="tool-name">${esc(e.name)}</span> ${e.is_error ? '✗' : '✓'}` + 257 + `<div class="tool-output">${esc(truncate(e.output, 2000))}</div>`; 258 + break; 259 + case 'error': 260 + div.textContent = '✗ ' + e.message; 261 + break; 262 + case 'sync': 263 + div.textContent = '↻ sync: ' + e.action; 264 + break; 265 + case 'turn_complete': 266 + currentTurn = e.turn; 267 + if (e.cost_usd) totalCost += e.cost_usd; 268 + turnEl.textContent = 'Turn: ' + currentTurn; 269 + costEl.textContent = 'Cost: $' + totalCost.toFixed(4); 270 + div.textContent = `Turn ${e.turn} complete` + (e.cost_usd ? ` ($${e.cost_usd.toFixed(4)})` : ''); 271 + break; 272 + case 'agent_start': 273 + div.textContent = '▶ Agent started'; 274 + div.style.borderColor = 'var(--green)'; 275 + break; 276 + case 'agent_stop': 277 + div.textContent = '■ Agent stopped'; 278 + div.style.borderColor = 'var(--red)'; 279 + break; 280 + default: 281 + div.textContent = JSON.stringify(e); 282 + } 283 + 284 + events.appendChild(div); 285 + events.scrollTop = events.scrollHeight; 286 + } 287 + 288 + function esc(s) { 289 + return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); 290 + } 291 + 292 + function truncate(s, n) { 293 + return s.length > n ? s.slice(0, n) + '...[truncated]' : s; 294 + } 295 + 296 + function formatJson(s) { 297 + try { 298 + return JSON.stringify(JSON.parse(s), null, 2); 299 + } catch { 300 + return s; 301 + } 302 + } 303 + 304 + connect(); 305 + </script> 306 + </body> 307 + </html>|} 308 + 309 + (* We don't track clients for WebSocket broadcasting in this simple implementation. 310 + Instead, each WebSocket connection runs in its own fiber and subscribes to events. *) 311 + 312 + type t = unit 313 + 314 + let create _event_bus = () 315 + 316 + (* Broadcast is now a no-op since each connection handles its own events *) 317 + let broadcast _t _event = () 318 + 319 + (* Handle WebSocket connection - each connection subscribes to events directly *) 320 + let handle_websocket event_bus (flow : _ Eio.Net.stream_socket) = 321 + let closed = ref false in 322 + Log.info (fun m -> m "WebSocket client connected"); 323 + 324 + (* Subscribe to events and send them to this client *) 325 + let listener event = 326 + if not !closed then begin 327 + try 328 + let json = Event.to_json event in 329 + Ws.send_text flow json 330 + with _ -> 331 + closed := true 332 + end 333 + in 334 + Event.subscribe event_bus listener; 335 + 336 + (* Read loop - handle pings and close *) 337 + let rec loop () = 338 + match Ws.read_frame flow with 339 + | None -> 340 + closed := true 341 + | Some (0x8, _) -> (* Close *) 342 + Ws.send_close flow; 343 + closed := true 344 + | Some (0x9, data) -> (* Ping -> Pong *) 345 + let pong = Bytes.create (2 + String.length data) in 346 + Bytes.set_uint8 pong 0 0x8A; (* FIN + pong *) 347 + Bytes.set_uint8 pong 1 (String.length data); 348 + Bytes.blit_string data 0 pong 2 (String.length data); 349 + Eio.Flow.copy_string (Bytes.to_string pong) flow; 350 + loop () 351 + | Some _ -> 352 + loop () 353 + in 354 + (try loop () with _ -> ()); 355 + closed := true; 356 + Event.unsubscribe event_bus listener; 357 + Log.info (fun m -> m "WebSocket client disconnected") 358 + 359 + (* Parse HTTP request headers *) 360 + let parse_request data = 361 + let lines = String.split_on_char '\n' data in 362 + let headers = Hashtbl.create 16 in 363 + let path = ref "/" in 364 + List.iteri (fun i line -> 365 + let line = String.trim line in 366 + if i = 0 then begin 367 + (* Request line: GET /path HTTP/1.1 *) 368 + match String.split_on_char ' ' line with 369 + | _ :: p :: _ -> path := p 370 + | _ -> () 371 + end else begin 372 + match String.index_opt line ':' with 373 + | Some idx -> 374 + let key = String.lowercase_ascii (String.trim (String.sub line 0 idx)) in 375 + let value = String.trim (String.sub line (idx + 1) (String.length line - idx - 1)) in 376 + Hashtbl.add headers key value 377 + | None -> () 378 + end 379 + ) lines; 380 + (!path, headers) 381 + 382 + (* Handle HTTP request *) 383 + let handle_request event_bus (flow : _ Eio.Net.stream_socket) = 384 + let buf = Buffer.create 4096 in 385 + let chunk = Cstruct.create 4096 in 386 + 387 + (* Read request *) 388 + let rec read_headers () = 389 + match Eio.Flow.single_read flow chunk with 390 + | n -> 391 + Buffer.add_string buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); 392 + let data = Buffer.contents buf in 393 + if String.length data > 4 && 394 + String.sub data (String.length data - 4) 4 = "\r\n\r\n" 395 + then data 396 + else read_headers () 397 + | exception End_of_file -> Buffer.contents buf 398 + in 399 + let request = read_headers () in 400 + let (path, headers) = parse_request request in 401 + 402 + Log.debug (fun m -> m "Request: %s" path); 403 + 404 + (* Check for WebSocket upgrade *) 405 + let is_upgrade = 406 + Hashtbl.find_opt headers "upgrade" = Some "websocket" && 407 + Hashtbl.mem headers "sec-websocket-key" 408 + in 409 + 410 + if path = "/ws" && is_upgrade then begin 411 + (* WebSocket handshake *) 412 + let key = Hashtbl.find headers "sec-websocket-key" in 413 + let accept = Ws.accept_key key in 414 + let response = Printf.sprintf 415 + "HTTP/1.1 101 Switching Protocols\r\n\ 416 + Upgrade: websocket\r\n\ 417 + Connection: Upgrade\r\n\ 418 + Sec-WebSocket-Accept: %s\r\n\r\n" accept 419 + in 420 + Eio.Flow.copy_string response flow; 421 + handle_websocket event_bus flow 422 + end else begin 423 + (* Serve static content *) 424 + let (status, content_type, body) = 425 + if path = "/" || path = "/index.html" then 426 + ("200 OK", "text/html", index_html) 427 + else 428 + ("404 Not Found", "text/plain", "Not Found") 429 + in 430 + let response = Printf.sprintf 431 + "HTTP/1.1 %s\r\n\ 432 + Content-Type: %s\r\n\ 433 + Content-Length: %d\r\n\ 434 + Connection: close\r\n\r\n%s" 435 + status content_type (String.length body) body 436 + in 437 + Eio.Flow.copy_string response flow 438 + end 439 + 440 + (* Start the web server *) 441 + let start ~sw ~net ~port event_bus = 442 + let t = create event_bus in 443 + 444 + (* Listen for connections *) 445 + let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, port) in 446 + let socket = Eio.Net.listen net ~sw ~reuse_addr:true ~backlog:10 addr in 447 + Log.info (fun m -> m "Web server listening on http://localhost:%d" port); 448 + 449 + (* Accept loop *) 450 + let rec accept_loop () = 451 + let flow, _addr = Eio.Net.accept ~sw socket in 452 + Eio.Fiber.fork ~sw (fun () -> 453 + try handle_request event_bus flow 454 + with exn -> 455 + Log.warn (fun m -> m "Request error: %s" (Printexc.to_string exn)) 456 + ); 457 + accept_loop () 458 + in 459 + Eio.Fiber.fork ~sw accept_loop; 460 + t
+23
lib/claude/web.mli
··· 1 + (** Minimal WebSocket server for live agent UI. 2 + 3 + Serves a single-page web UI that displays agent events in real-time. *) 4 + 5 + type t 6 + (** Web server state. *) 7 + 8 + val start : 9 + sw:Eio.Switch.t -> 10 + net:_ Eio.Net.t -> 11 + port:int -> 12 + Event.bus -> 13 + t 14 + (** [start ~sw ~net ~port event_bus] starts the web server. 15 + 16 + Listens on [port] and serves: 17 + - GET / - The HTML UI 18 + - WS /ws - WebSocket for event streaming 19 + 20 + Subscribes to [event_bus] and broadcasts all events to connected clients. *) 21 + 22 + val broadcast : t -> Event.t -> unit 23 + (** [broadcast t event] sends an event to all connected WebSocket clients. *)
+2
unpac-claude.opam
··· 14 14 "cmdliner" {>= "1.2.0"} 15 15 "logs" {>= "0.7.0"} 16 16 "fmt" {>= "0.9.0"} 17 + "digestif" {>= "1.0.0"} 18 + "base64" {>= "3.0.0"} 17 19 "odoc" {with-doc} 18 20 ] 19 21 build: [