defmodule Mix.Tasks.Agy do @moduledoc """ Runs `agy` in headless mode with prompts tailored to project workflows. Examples: mix agy --mode plan path/to/implementation_plan.md --dry-run mix agy --mode walkthrough path/to/walkthrough.md --timeout 10m mix agy --mode commit-review path/to/walkthrough.md --skip-permissions mix agy --mode plan path/to/implementation_plan.md --save-conversation release-review mix agy --conversation-name release-review --mode walkthrough path/to/walkthrough.md mix agy --conversation-name release-review --log-file auto --mode plan path/to/implementation_plan.md mix agy --conversation-name release-review --watchdog-timeout 30m --mode plan path/to/implementation_plan.md The task defaults to safe execution: it does not pass `--dangerously-skip-permissions` unless `--skip-permissions` is explicitly provided. """ use Mix.Task @shortdoc "Run agy headless with project workflow prompts" @readme_path Path.expand("../../../README.md", __DIR__) @external_resource @readme_path @readme File.read!(@readme_path) @switches [ mode: :string, prompt: :string, conversation: :string, conversation_name: :string, save_conversation: :string, list_conversations: :boolean, forget_conversation: :string, conversation_store: :string, agy_brain_dir: :string, log_file: :string, run_log: :string, watchdog_timeout: :string, continue: :boolean, timeout: :string, add_dir: :keep, agy: :string, cwd: :string, skip_permissions: :boolean, dry_run: :boolean, readme: :boolean, help: :boolean ] @aliases [ m: :mode, p: :prompt ] @modes ~w(review plan walkthrough refine commit-review ask) @impl Mix.Task def run(argv) do case parse(argv) do {:help, help} -> Mix.shell().info(help) {:readme, readme} -> Mix.shell().info(readme) {:ok, opts, files} -> opts = put_conversation_paths(opts) cond do opts[:list_conversations] -> opts[:conversation_store] |> read_conversations() |> format_conversations() |> Mix.shell().info() opts[:forget_conversation] -> case forget_conversation(opts[:conversation_store], opts[:forget_conversation]) do :ok -> Mix.shell().info( "Forgot agy conversation #{inspect(opts[:forget_conversation])}." ) {:error, reason} -> Mix.raise(reason) end true -> case resolve_conversation(opts) do {:ok, opts} -> prompt = prompt(opts[:mode], files, opts[:prompt]) {command, command_args} = command(prompt, opts) if opts[:dry_run] do Mix.shell().info(format_dry_run(command, command_args, prompt)) else print_log_hints(opts) prepare_log_files(opts) run_command(command, command_args, opts, prompt) maybe_save_conversation(opts) end {:error, reason} -> Mix.raise(reason) end end {:error, reason} -> Mix.raise(reason) end end def parse(argv) do {opts, files, invalid} = OptionParser.parse(argv, strict: @switches, aliases: @aliases) cond do opts[:help] -> {:help, usage()} opts[:readme] -> {:readme, readme()} invalid != [] -> {:error, "Invalid option(s): #{Enum.map_join(invalid, ", ", &elem(&1, 0))}"} conversation_selector_count(opts) > 1 -> {:error, "Choose only one of --conversation, --conversation-name, or --continue."} opts[:mode] && opts[:mode] not in @modes -> {:error, "Unknown mode #{inspect(opts[:mode])}. Supported modes: #{Enum.join(@modes, ", ")}"} files == [] && blank?(opts[:prompt]) && !conversation_action?(opts) -> {:error, "Provide at least one file path or --prompt text."} true -> opts = Keyword.put_new(opts, :mode, default_mode(files)) {:ok, opts, files} end end def readme, do: @readme def prompt(mode, files, extra_prompt \\ nil) when mode in @modes do files_block = case files do [] -> "No explicit files were provided." paths -> paths |> Enum.map(&Path.expand/1) |> Enum.map_join("\n", &"- #{&1}") end extra_block = if blank?(extra_prompt) do "No extra instruction." else String.trim(extra_prompt) end """ You are running inside the current project through `agy` headless mode. Work in a concise, implementation-oriented style. Mode: #{mode} Files: #{files_block} Extra instruction: #{extra_block} #{mode_instruction(mode)} Operational rules: - Prefer reading the listed files and the local project before answering. - Keep changes focused on the requested mode and files. - Do not introduce unrelated refactors. - Do not commit unless the mode explicitly says `commit-review` or the extra instruction explicitly requests a commit. - If you run commands, report the exact verification commands and results. """ |> String.trim() end def command(prompt, opts \\ []) do agy = opts[:agy] || "agy" args = ["-p", prompt] |> add_timeout(opts[:timeout]) |> add_continue(opts[:continue]) |> add_conversation(opts[:resolved_conversation] || opts[:conversation]) |> add_log_file(opts[:log_file]) |> add_dirs(Keyword.get_values(opts, :add_dir)) |> add_skip_permissions(opts[:skip_permissions]) {agy, args} end def usage do """ Usage: mix agy [PATH ...] [options] Options: --mode MODE review | plan | walkthrough | refine | commit-review | ask --prompt TEXT, -p TEXT Extra instruction appended to the generated prompt --timeout DURATION Forwarded as --print-timeout, for example 10m --conversation UUID Continue a specific agy conversation --conversation-name NAME Continue a named conversation from the local registry --save-conversation NAME Save the selected or latest agy conversation under NAME --list-conversations List named agy conversations stored for this repo --forget-conversation N Remove a named agy conversation from the registry --conversation-store P Registry path, defaults to .antigravitycli/agy_conversations.tsv --agy-brain-dir PATH agy brain directory used to discover latest conversations --log-file PATH|auto Forward --log-file to agy; "auto" writes under .antigravitycli/logs --run-log PATH|auto|none Wrapper audit log, defaults to "auto" --watchdog-timeout DUR Kill agy after no stdout/log/transcript activity for a duration --continue Forward --continue to agy for its most recent conversation --add-dir PATH Forwarded to agy; may be repeated --agy PATH agy executable path, defaults to "agy" --cwd PATH Working directory for the agy process --skip-permissions Forward --dangerously-skip-permissions --dry-run Print command shape and prompt without executing agy --readme Print the embedded README for agent-facing context Examples: mix agy --readme mix agy --mode plan path/to/implementation_plan.md --dry-run mix agy --mode walkthrough path/to/walkthrough.md --timeout 10m mix agy --mode commit-review path/to/walkthrough.md --skip-permissions mix agy --mode plan implementation_plan.md --save-conversation release-review mix agy --conversation-name release-review --mode walkthrough walkthrough.md mix agy --conversation-name release-review --log-file auto --mode plan implementation_plan.md mix agy --conversation-name release-review --watchdog-timeout 30m --mode plan implementation_plan.md mix agy --list-conversations """ |> String.trim() end defp run_command(command, command_args, opts, prompt) do cwd = opts[:cwd] || File.cwd!() log_path = opts[:run_log] watchdog_ms = parse_watchdog_timeout!(opts[:watchdog_timeout]) executable = System.find_executable(command) || command started_at = System.monotonic_time(:millisecond) activity_paths = activity_paths(opts) activity_state = activity_state(activity_paths) append_run_log(log_path, """ [#{timestamp()}] RUN-START cwd=#{cwd} command=#{format_command_shape(command, command_args, prompt)} agy_log=#{opts[:log_file] || "none"} transcript=#{transcript_path(opts) || "unknown"} watchdog_idle_timeout=#{opts[:watchdog_timeout] || "none"} """) {spawn_executable, spawn_args} = spawn_with_closed_stdin(executable, command_args) port = Port.open({:spawn_executable, spawn_executable}, [ :binary, :exit_status, :stderr_to_stdout, {:args, spawn_args}, {:cd, cwd} ]) result = collect_port( port, log_path, watchdog_ms, started_at, started_at, started_at, activity_state ) case result do {:exit, 0} -> append_run_log(log_path, "[#{timestamp()}] RUN-END status=0\n") :ok {:exit, status} -> append_run_log(log_path, "[#{timestamp()}] RUN-END status=#{status}\n") Mix.raise("agy exited with status #{status}") {:timeout, idle_ms} -> append_run_log( log_path, "[#{timestamp()}] RUN-END watchdog_idle_timeout idle_ms=#{idle_ms}\n" ) Mix.raise("agy watchdog idle timeout after #{idle_ms}ms") end end defp collect_port( port, log_path, watchdog_ms, started_at, last_activity_at, last_heartbeat_at, activity_state ) do receive do {^port, {:data, data}} -> IO.write(data) append_run_log(log_path, data) now = System.monotonic_time(:millisecond) collect_port( port, log_path, watchdog_ms, started_at, now, last_heartbeat_at, activity_state ) {^port, {:exit_status, status}} -> {:exit, status} after 1_000 -> now = System.monotonic_time(:millisecond) elapsed_ms = now - started_at {activity?, activity_state} = activity_changed?(activity_state) last_activity_at = if activity?, do: now, else: last_activity_at cond do watchdog_ms && now - last_activity_at >= watchdog_ms -> kill_port(port) {:timeout, now - last_activity_at} activity? -> append_run_log(log_path, "[#{timestamp()}] RUN-ACTIVITY elapsed_ms=#{elapsed_ms}\n") collect_port( port, log_path, watchdog_ms, started_at, last_activity_at, last_heartbeat_at, activity_state ) now - last_heartbeat_at >= 30_000 -> append_run_log(log_path, "[#{timestamp()}] RUN-WAIT elapsed_ms=#{elapsed_ms}\n") collect_port( port, log_path, watchdog_ms, started_at, last_activity_at, now, activity_state ) true -> collect_port( port, log_path, watchdog_ms, started_at, last_activity_at, last_heartbeat_at, activity_state ) end end end defp kill_port(port) do os_pid = case Port.info(port, :os_pid) do {:os_pid, pid} when is_integer(pid) -> pid _ -> nil end if os_pid do System.cmd("kill", ["-TERM", Integer.to_string(os_pid)], stderr_to_stdout: true) Process.sleep(100) if process_alive?(os_pid) do System.cmd("kill", ["-KILL", Integer.to_string(os_pid)], stderr_to_stdout: true) end end Port.close(port) rescue ArgumentError -> :ok end defp spawn_with_closed_stdin(executable, args) do shell = System.find_executable("sh") || "/bin/sh" script = "exec true {_output, _status} -> false end end defp maybe_save_conversation(opts) do case opts[:save_conversation] do nil -> :ok name -> conversation_id = opts[:resolved_conversation] || opts[:conversation] || latest_conversation_id(opts[:agy_brain_dir]) if blank?(conversation_id) do Mix.raise("Could not discover an agy conversation id to save as #{inspect(name)}.") end case save_conversation(opts[:conversation_store], name, conversation_id) do :ok -> Mix.shell().info("Saved agy conversation #{conversation_id} as #{inspect(name)}.") {:error, reason} -> Mix.raise(reason) end end end defp print_log_hints(opts) do if opts[:run_log] do Mix.shell().info("Wrapper run log: #{opts[:run_log]}") Mix.shell().info("tail -f #{opts[:run_log]}") end if opts[:log_file] do Mix.shell().info("agy log file: #{opts[:log_file]}") Mix.shell().info("tail -f #{opts[:log_file]}") end case transcript_path(opts) do nil -> :ok path -> Mix.shell().info("agy transcript: #{path}") Mix.shell().info("tail -f #{path}") end end defp prepare_log_files(opts) do Enum.each([opts[:run_log], opts[:log_file]], fn nil -> :ok path -> File.mkdir_p!(Path.dirname(path)) end) end defp mode_instruction("review") do """ Review the listed file(s) against the current project state. Lead with actionable findings, then summarize residual risks. Edit only if the extra instruction asks you to edit. """ end defp mode_instruction("plan") do """ Read the implementation plan, answer open questions, tighten the engineering boundary, and make the plan directly actionable. Prefer explicit decisions over broad options. """ end defp mode_instruction("walkthrough") do """ Read the walkthrough, inspect the corresponding project changes, review for correctness gaps, and propose or apply focused fixes only when requested. """ end defp mode_instruction("refine") do """ Refine the listed Markdown or notes in place. Preserve the thesis, remove chat residue, tighten claims, and keep the result bounded. """ end defp mode_instruction("commit-review") do """ Review the project against the listed walkthrough or plan, fix actionable issues, run focused verification, and create a focused git commit. Do not include unrelated dirty work. """ end defp mode_instruction("ask") do """ Answer the extra instruction using the listed files as context. Prefer a direct answer unless code edits are explicitly requested. """ end defp default_mode(files) do case Enum.map(files, &Path.basename/1) do ["implementation_plan.md" | _] -> "plan" ["walkthrough.md" | _] -> "walkthrough" _ -> "review" end end defp add_timeout(args, nil), do: args defp add_timeout(args, timeout), do: args ++ ["--print-timeout", timeout] defp add_continue(args, true), do: args ++ ["--continue"] defp add_continue(args, _), do: args defp add_conversation(args, nil), do: args defp add_conversation(args, conversation), do: args ++ ["--conversation", conversation] defp add_log_file(args, nil), do: args defp add_log_file(args, log_file), do: args ++ ["--log-file", log_file] defp add_dirs(args, dirs) do Enum.reduce(dirs, args, fn dir, acc -> acc ++ ["--add-dir", dir] end) end defp add_skip_permissions(args, true), do: args ++ ["--dangerously-skip-permissions"] defp add_skip_permissions(args, _), do: args defp format_dry_run(command, command_args, prompt) do safe_args = command_args |> Enum.map(fn ^prompt -> "" arg -> arg end) |> Enum.join(" ") """ Command: #{command} #{safe_args} Prompt: #{prompt} """ |> String.trim() end defp put_conversation_paths(opts) do cwd = opts[:cwd] || File.cwd!() opts |> Keyword.put( :conversation_store, opts[:conversation_store] || Path.expand(".antigravitycli/agy_conversations.tsv", cwd) ) |> Keyword.put( :agy_brain_dir, opts[:agy_brain_dir] || Path.join([System.user_home!(), ".gemini", "antigravity-cli", "brain"]) ) |> put_log_file_path(cwd) |> put_run_log_path(cwd) end defp put_log_file_path(opts, cwd) do case opts[:log_file] do nil -> opts "auto" -> timestamp = DateTime.utc_now() |> DateTime.truncate(:second) |> Calendar.strftime("%Y%m%dT%H%M%SZ") mode = opts[:mode] || "agy" path = Path.expand(".antigravitycli/logs/#{timestamp}-#{mode}.log", cwd) Keyword.put(opts, :log_file, path) path -> Keyword.put(opts, :log_file, Path.expand(path, cwd)) end end defp put_run_log_path(opts, cwd) do case opts[:run_log] do "none" -> Keyword.delete(opts, :run_log) nil -> Keyword.put(opts, :run_log, auto_log_path(opts, cwd, "run.log")) "auto" -> Keyword.put(opts, :run_log, auto_log_path(opts, cwd, "run.log")) path -> Keyword.put(opts, :run_log, Path.expand(path, cwd)) end end defp auto_log_path(opts, cwd, suffix) do timestamp = DateTime.utc_now() |> DateTime.truncate(:second) |> Calendar.strftime("%Y%m%dT%H%M%SZ") mode = opts[:mode] || "agy" Path.expand(".antigravitycli/logs/#{timestamp}-#{mode}.#{suffix}", cwd) end defp resolve_conversation(opts) do conversations = read_conversations(opts[:conversation_store]) cond do opts[:conversation_name] -> case find_conversation(conversations, opts[:conversation_name]) do nil -> {:error, "No agy conversation named #{inspect(opts[:conversation_name])}."} %{id: conversation_id} -> {:ok, Keyword.put(opts, :resolved_conversation, conversation_id)} end opts[:conversation] -> conversation_id = case find_conversation(conversations, opts[:conversation]) do nil -> opts[:conversation] %{id: id} -> id end {:ok, Keyword.put(opts, :resolved_conversation, conversation_id)} true -> {:ok, opts} end end defp read_conversations(path) do case File.read(path) do {:ok, contents} -> contents |> String.split("\n", trim: true) |> Enum.flat_map(&parse_conversation_line/1) |> Enum.sort_by(& &1.name) {:error, :enoent} -> [] {:error, reason} -> Mix.raise("Could not read agy conversation store #{path}: #{:file.format_error(reason)}") end end defp parse_conversation_line(line) do case String.split(line, "\t") do [name, id, updated_at] when name != "" and id != "" -> [%{name: name, id: id, updated_at: updated_at}] [name, id] when name != "" and id != "" -> [%{name: name, id: id, updated_at: ""}] _ -> [] end end defp save_conversation(path, name, conversation_id) do cond do blank?(name) -> {:error, "Conversation name cannot be blank."} blank?(conversation_id) -> {:error, "Conversation id cannot be blank."} true -> updated_at = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() conversations = path |> read_conversations() |> Enum.reject(&(&1.name == name)) |> then(&[%{name: name, id: conversation_id, updated_at: updated_at} | &1]) |> Enum.sort_by(& &1.name) File.mkdir_p!(Path.dirname(path)) File.write(path, encode_conversations(conversations)) end end defp forget_conversation(path, name) do conversations = read_conversations(path) if Enum.any?(conversations, &(&1.name == name)) do conversations = conversations |> Enum.reject(&(&1.name == name)) |> Enum.sort_by(& &1.name) File.mkdir_p!(Path.dirname(path)) File.write(path, encode_conversations(conversations)) else {:error, "No agy conversation named #{inspect(name)}."} end end defp encode_conversations(conversations) do conversations |> Enum.map_join("\n", fn conversation -> Enum.join([conversation.name, conversation.id, conversation.updated_at], "\t") end) |> case do "" -> "" contents -> contents <> "\n" end end defp format_conversations([]), do: "No named agy conversations are stored." defp format_conversations(conversations) do conversations |> Enum.map_join("\n", fn conversation -> suffix = if blank?(conversation.updated_at) do "" else " updated_at=#{conversation.updated_at}" end "#{conversation.name}\t#{conversation.id}#{suffix}" end) end defp latest_conversation_id(nil), do: nil defp latest_conversation_id(brain_dir) do brain_dir = Path.expand(brain_dir) with {:ok, entries} <- File.ls(brain_dir) do entries |> Enum.map(&Path.join(brain_dir, &1)) |> Enum.filter(&File.dir?/1) |> Enum.flat_map(fn path -> name = Path.basename(path) if uuid?(name) do case File.stat(path, time: :posix) do {:ok, stat} -> [{stat.mtime, name}] {:error, _reason} -> [] end else [] end end) |> Enum.sort_by(&elem(&1, 0), :desc) |> case do [{_mtime, conversation_id} | _] -> conversation_id [] -> nil end else {:error, _reason} -> nil end end defp transcript_path(opts) do conversation_id = opts[:resolved_conversation] || opts[:conversation] if uuid?(to_string(conversation_id)) do Path.join([ opts[:agy_brain_dir], conversation_id, ".system_generated", "logs", "transcript.jsonl" ]) end end defp activity_paths(opts) do [opts[:log_file], transcript_path(opts)] |> Enum.reject(&blank?/1) end defp activity_state(paths) do Map.new(paths, fn path -> {path, file_signature(path)} end) end defp activity_changed?(state) do new_state = Map.new(state, fn {path, _signature} -> {path, file_signature(path)} end) {new_state != state, new_state} end defp file_signature(path) do case File.stat(path, time: :posix) do {:ok, stat} -> {stat.size, stat.mtime} {:error, _reason} -> :missing end end defp append_run_log(nil, _data), do: :ok defp append_run_log(path, data), do: File.write!(path, data, [:append]) defp format_command_shape(command, command_args, prompt) do safe_args = command_args |> Enum.map(fn ^prompt -> "" arg -> arg end) |> Enum.join(" ") "#{command} #{safe_args}" end defp parse_watchdog_timeout!(nil), do: nil defp parse_watchdog_timeout!("none"), do: nil defp parse_watchdog_timeout!(duration) do duration = String.trim(to_string(duration)) case Regex.run(~r/\A(\d+)(ms|s|m|h)?\z/, duration, capture: :all_but_first) do [amount] -> String.to_integer(amount) * 1_000 [amount, unit] -> String.to_integer(amount) * duration_factor(unit) _ -> Mix.raise( "Invalid --watchdog-timeout #{inspect(duration)}. Use forms like 500ms, 30s, 20m, or 1h." ) end end defp duration_factor("ms"), do: 1 defp duration_factor("s"), do: 1_000 defp duration_factor("m"), do: 60_000 defp duration_factor("h"), do: 3_600_000 defp timestamp do DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() end defp find_conversation(conversations, name) do Enum.find(conversations, &(&1.name == name)) end defp uuid?(value) do Regex.match?( ~r/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i, value ) end defp conversation_selector_count(opts) do [opts[:conversation], opts[:conversation_name], opts[:continue]] |> Enum.count(fn value -> value not in [nil, false] end) end defp conversation_action?(opts) do opts[:list_conversations] || opts[:forget_conversation] end defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == "" end