package miaou-runner

  1. Overview
  2. Docs

Source file headless_json_runner.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
(******************************************************************************)
(*                                                                            *)
(* SPDX-License-Identifier: MIT                                               *)
(* Copyright (c) 2026 Mathias Bourgoin <mathias.bourgoin@atacama.tech>        *)
(*                                                                            *)
(******************************************************************************)

(** Headless JSON-over-stdio runner.  Activated when [MIAOU_DRIVER=headless].
    Reads newline-delimited JSON commands from stdin and writes JSON responses
    to stdout.  The app binary becomes a subprocess that an AI agent or CI
    script can drive without a real terminal. *)

[@@@warning "-32-34-37-69"]

module HD = Lib_miaou_internal.Headless_driver
module Tui_page = Miaou_core.Tui_page

(* ── ANSI strip ─────────────────────────────────────────────────────────── *)

(** Remove ANSI/VT escape sequences from [s], returning plain text. *)
let ansi_strip s =
  let buf = Buffer.create (String.length s) in
  let n = String.length s in
  let i = ref 0 in
  while !i < n do
    if s.[!i] = '\x1b' then (
      incr i ;
      if !i < n && s.[!i] = '[' then (
        (* CSI sequence: ESC [ ... <final byte 0x40-0x7e> *)
        incr i ;
        while !i < n && (s.[!i] < '@' || s.[!i] > '~') do
          incr i
        done ;
        if !i < n then incr i (* consume final byte *))
      else if !i < n then incr i (* skip single char after ESC (Fe sequence) *))
    else (
      Buffer.add_char buf s.[!i] ;
      incr i)
  done ;
  Buffer.contents buf

(* ── on_frame callback ──────────────────────────────────────────────────── *)

(** Optional callback invoked with the raw ANSI frame on every frame emit.
    Set by [run ~on_frame] before the command loop starts.  The callback
    receives the terminal dimensions (rows, cols) followed by the raw ANSI
    data so that a viewer can resize its terminal to match. *)
let on_frame_fn : (rows:int -> cols:int -> string -> unit) option ref = ref None

(* ── Frame helpers ───────────────────────────────────────────────────────── *)

let current_frame () =
  let size = HD.get_size () in
  let raw = HD.Screen.get () in
  (match !on_frame_fn with
  | Some f -> f ~rows:size.LTerm_geom.rows ~cols:size.LTerm_geom.cols raw
  | None -> ()) ;
  let text = ansi_strip raw in
  `Assoc
    [
      ("type", `String "frame");
      ("text", `String text);
      ("rows", `Int size.LTerm_geom.rows);
      ("cols", `Int size.LTerm_geom.cols);
    ]

let nav_response action =
  `Assoc [("type", `String "nav"); ("action", `String action)]

let error_response msg =
  `Assoc [("type", `String "error"); ("message", `String msg)]

let emit json =
  (print_string [@allow_forbidden "headless runner writes JSON to stdout"])
    (Yojson.Safe.to_string json) ;
  (print_char [@allow_forbidden "headless runner writes JSON to stdout"]) '\n' ;
  (flush [@allow_forbidden "headless runner flushes stdout"]) stdout

(* ── Command dispatch ────────────────────────────────────────────────────── *)

(** Process one parsed command.  Returns [true] to keep running, [false] to
    stop the loop. *)
let handle_cmd (cmd : (string * Yojson.Safe.t) list) : bool =
  let get_string key =
    match List.assoc_opt key cmd with Some (`String s) -> Some s | _ -> None
  in
  let get_int key =
    match List.assoc_opt key cmd with Some (`Int n) -> Some n | _ -> None
  in
  match get_string "cmd" with
  | None ->
      emit (error_response "Missing 'cmd' field") ;
      true
  | Some "render" ->
      emit (current_frame ()) ;
      true
  | Some "key" -> (
      match get_string "key" with
      | None ->
          emit (error_response "Missing 'key' field") ;
          true
      | Some k -> (
          let outcome = HD.Stateful.send_key k in
          (* Run a few idle ticks so timers/refresh settle *)
          let outcome =
            match outcome with
            | `Continue -> HD.Stateful.idle_wait ~iterations:3 ()
            | other -> other
          in
          match outcome with
          | `Quit ->
              emit (nav_response "quit") ;
              false
          | `Back ->
              emit (nav_response "back") ;
              false
          | `SwitchTo name ->
              emit (nav_response ("switch:" ^ name)) ;
              true
          | `Continue ->
              emit (current_frame ()) ;
              true))
  | Some "click" ->
      (* Click is not yet implemented in Stateful; just re-render *)
      ignore (get_int "row") ;
      ignore (get_int "col") ;
      emit (current_frame ()) ;
      true
  | Some "tick" -> (
      let n = Option.value ~default:1 (get_int "n") in
      let outcome = HD.Stateful.idle_wait ~iterations:n () in
      (match outcome with
      | `Quit -> emit (nav_response "quit")
      | `Back -> emit (nav_response "back")
      | `SwitchTo name -> emit (nav_response ("switch:" ^ name))
      | `Continue -> emit (current_frame ())) ;
      match outcome with `Quit | `Back -> false | _ -> true)
  | Some "resize" -> (
      match (get_int "rows", get_int "cols") with
      | Some rows, Some cols ->
          HD.set_size rows cols ;
          emit (current_frame ()) ;
          true
      | _ ->
          emit (error_response "Missing 'rows' or 'cols'") ;
          true)
  | Some "quit" ->
      emit (nav_response "quit") ;
      false
  | Some other ->
      emit (error_response (Printf.sprintf "Unknown command: %s" other)) ;
      true

(* ── Main entry point ────────────────────────────────────────────────────── *)

let run ?on_frame (page : (module Tui_page.PAGE_SIG)) =
  on_frame_fn := on_frame ;
  (* Redirect stderr to /dev/null unless verbose mode requested *)
  (match Sys.getenv_opt "MIAOU_HEADLESS_VERBOSE" with
  | None | Some "" -> (
      try
        let null_fd = Unix.openfile "/dev/null" [Unix.O_WRONLY] 0 in
        Unix.dup2 null_fd Unix.stderr ;
        Unix.close null_fd
      with _ -> ())
  | _ -> ()) ;
  (* Unpack and repack to give OCaml a concrete module with an abstract state
     type, avoiding first-class module type-path issues with Stateful.init. *)
  let module P = (val page : Tui_page.PAGE_SIG) in
  HD.Stateful.init (module P) ;
  let rec loop () =
    (* Use run_in_systhread so the blocking input_line runs in a system
       thread.  This keeps the eio event loop active, allowing background
       fibers (e.g. LLM subprocess I/O) to make progress between commands. *)
    match
      Eio_unix.run_in_systhread ~label:"headless-stdin" (fun () ->
          input_line stdin)
    with
    | exception End_of_file -> ()
    | line -> (
        let line = String.trim line in
        if line = "" then loop ()
        else
          match Yojson.Safe.from_string line with
          | exception Yojson.Json_error msg ->
              emit (error_response ("JSON parse error: " ^ msg)) ;
              loop ()
          | `Assoc pairs -> if handle_cmd pairs then loop ()
          | _ ->
              emit (error_response "Expected a JSON object") ;
              loop ())
  in
  (* When a viewer is attached, fork a daemon fiber that periodically
     re-renders the screen and broadcasts changes.  Eio's cooperative
     scheduling ensures the refresh fiber only runs while the main loop
     is blocked on stdin (run_in_systhread), so there is no concurrent
     access to the headless driver state. *)
  (match !on_frame_fn with
  | Some _ ->
      Eio.Switch.run @@ fun sw ->
      Eio.Fiber.fork_daemon ~sw (fun () ->
          let prev = ref "" in
          while true do
            Eio_unix.sleep 0.2 ;
            (* Read the cached screen content written by the command handler.
               Do NOT call idle_wait here — that would race with the command
               handler's own idle_wait (both mutate the shared page-state ref
               and double-tick clocks/timers whenever the command handler
               yields).  The cache is always fresh: every tick/key/render
               command updates it before returning a response. *)
            let size = HD.get_size () in
            let raw = HD.Screen.get () in
            if raw <> !prev then (
              prev := raw ;
              match !on_frame_fn with
              | Some f ->
                  f ~rows:size.LTerm_geom.rows ~cols:size.LTerm_geom.cols raw
              | None -> ())
          done ;
          `Stop_daemon) ;
      loop ()
  | None -> loop ()) ;
  (* Exit cleanly — same pattern as Tui_driver_common which calls exit 0
     after its event loop to avoid blocking on pending Eio fibers. *)
  exit 0