commit 32c6c8032a0f1d43aed9593294f6c3e259e17b58
Author: Ben Sima <ben@bensima.com>
Date: Thu Jan 1 21:35:13 2026
Omni/Agentd: Add agentd CLI for container runtime
agentd runs agent workflows in isolated OCI containers.
Commands:
- agentd run <spec.md> - Run a workflow spec
- agentd ps - List running containers
- agentd logs <id> - Show container logs
- agentd stop <id> - Stop a container
- agentd images - List available toolchain images
- agentd test - Run tests
Spec format: Markdown with YAML frontmatter defining:
- toolchain (base/git/haskell)
- workspace (path to mount rw)
- model, provider, cost/iteration limits
Container mounts:
- workspace -> /workspace (rw)
- repo root -> /repo (ro)
- ~/.config/agent -> /root/.config/agent (ro)
Task-Id: t-320.4, t-320.5, t-320.6
diff --git a/Omni/Agentd.hs b/Omni/Agentd.hs
new file mode 100644
index 00000000..b9732925
--- /dev/null
+++ b/Omni/Agentd.hs
@@ -0,0 +1,327 @@
+#!/usr/bin/env run.sh
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Agentd - Container runtime for agent workflows
+--
+-- Runs agent tasks in isolated OCI containers with proper mounts.
+--
+-- : out agentd
+-- : dep aeson
+-- : dep docopt
+-- : dep yaml
+module Omni.Agentd
+ ( main,
+ test,
+ )
+where
+
+import Alpha
+import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Char8 as BS
+import qualified Data.Text as Text
+import qualified Data.Text.IO as TextIO
+import qualified Data.Yaml as Yaml
+import qualified Omni.Cli as Cli
+import qualified Omni.Test as Test
+import qualified System.Console.Docopt as Docopt
+import qualified System.Directory as Dir
+import qualified System.Environment as Env
+import qualified System.Exit as Exit
+import qualified System.Process as Process
+
+-- | CLI help
+help :: Cli.Docopt
+help =
+ [Cli.docopt|
+agentd - Container runtime for agent workflows
+
+Usage:
+ agentd run <spec> [--verbose]
+ agentd ps
+ agentd logs <run-id>
+ agentd stop <run-id>
+ agentd images
+ agentd test
+ agentd (-h | --help)
+
+Commands:
+ run <spec> Run a workflow spec (.md file)
+ ps List running workflows
+ logs <run-id> Show logs for a run
+ stop <run-id> Stop a running workflow
+ images List available toolchain images
+
+Options:
+ --verbose Show container commands
+ -h --help Show this help
+|]
+
+-- | Workflow spec parsed from markdown frontmatter
+data Spec = Spec
+ { specToolchain :: Text,
+ specWorkspace :: FilePath,
+ specModel :: Maybe Text,
+ specProvider :: Maybe Text,
+ specMaxCostCents :: Maybe Int,
+ specMaxIterations :: Maybe Int,
+ specTask :: Text
+ }
+ deriving (Show, Eq)
+
+-- | Frontmatter fields
+data Frontmatter = Frontmatter
+ { fmToolchain :: Text,
+ fmWorkspace :: Text,
+ fmModel :: Maybe Text,
+ fmProvider :: Maybe Text,
+ fmMaxCostCents :: Maybe Int,
+ fmMaxIterations :: Maybe Int
+ }
+ deriving (Show, Eq)
+
+instance Aeson.FromJSON Frontmatter where
+ parseJSON =
+ Aeson.withObject "Frontmatter" <| \o ->
+ Frontmatter
+ </ o
+ Aeson..: "toolchain"
+ <*> o
+ Aeson..: "workspace"
+ <*> o
+ Aeson..:? "model"
+ <*> o
+ Aeson..:? "provider"
+ <*> o
+ Aeson..:? "max_cost_cents"
+ <*> o
+ Aeson..:? "max_iterations"
+
+-- | Parse a spec file (markdown with YAML frontmatter)
+parseSpec :: Text -> Either Text Spec
+parseSpec content = do
+ -- Split frontmatter from body
+ let lines_ = Text.lines content
+ case lines_ of
+ ("---" : rest) ->
+ case break (== "---") rest of
+ (fmLines, "---" : bodyLines) -> do
+ let fmText = Text.unlines fmLines
+ body = Text.strip <| Text.unlines bodyLines
+ case Yaml.decodeEither' (BS.pack <| Text.unpack fmText) of
+ Left err -> Left <| "YAML parse error: " <> tshow err
+ Right fm ->
+ Right
+ Spec
+ { specToolchain = fmToolchain fm,
+ specWorkspace = Text.unpack <| fmWorkspace fm,
+ specModel = fmModel fm,
+ specProvider = fmProvider fm,
+ specMaxCostCents = fmMaxCostCents fm,
+ specMaxIterations = fmMaxIterations fm,
+ specTask = body
+ }
+ _ -> Left "Missing closing --- for frontmatter"
+ _ -> Left "Spec must start with --- (YAML frontmatter)"
+
+-- | Get the docker image name for a toolchain
+toolchainImage :: Text -> Text
+toolchainImage = \case
+ "base" -> "agent-base:latest"
+ "git" -> "agent-git:latest"
+ "haskell" -> "agent-haskell:latest"
+ other -> other -- allow custom image names
+
+-- | Run a workflow in a container
+runWorkflow :: Spec -> Bool -> IO Exit.ExitCode
+runWorkflow spec verbose = do
+ -- Get paths
+ coderoot <- Env.getEnv "CODEROOT"
+ home <- Env.getEnv "HOME"
+ workspaceAbs <- Dir.makeAbsolute (specWorkspace spec)
+
+ -- Build docker command
+ let image = toolchainImage (specToolchain spec)
+
+ -- Agent flags
+ modelFlag = maybe [] (\m -> ["--model=" <> Text.unpack m]) (specModel spec)
+ providerFlag = maybe [] (\p -> ["--provider=" <> Text.unpack p]) (specProvider spec)
+ costFlag = maybe [] (\c -> ["--max-cost=" <> show c]) (specMaxCostCents spec)
+ iterFlag = maybe [] (\i -> ["--max-iter=" <> show i]) (specMaxIterations spec)
+ agentFlags = modelFlag ++ providerFlag ++ costFlag ++ iterFlag
+
+ -- Docker run command
+ dockerArgs =
+ [ "run",
+ "--rm",
+ "-i",
+ -- Mount workspace (rw)
+ "-v",
+ workspaceAbs <> ":/workspace",
+ -- Mount repo (ro)
+ "-v",
+ coderoot <> ":/repo:ro",
+ -- Mount agent auth (ro)
+ "-v",
+ home <> "/.config/agent:/root/.config/agent:ro",
+ -- Pass through API keys
+ "-e",
+ "ANTHROPIC_API_KEY",
+ "-e",
+ "OPENROUTER_API_KEY",
+ "-e",
+ "KAGI_API_KEY",
+ -- Set working directory
+ "-w",
+ "/workspace",
+ -- Image
+ Text.unpack image,
+ -- Command: agent with task
+ "agent"
+ ]
+ ++ agentFlags
+ ++ [Text.unpack (specTask spec)]
+
+ when verbose <| do
+ TextIO.hPutStrLn stderr <| "docker " <> Text.unwords (map Text.pack dockerArgs)
+
+ -- Run container
+ (_, _, _, ph) <-
+ Process.createProcess
+ (Process.proc "docker" dockerArgs)
+ { Process.std_in = Process.Inherit,
+ Process.std_out = Process.Inherit,
+ Process.std_err = Process.Inherit
+ }
+ Process.waitForProcess ph
+
+-- | List running containers
+listRunning :: IO ()
+listRunning = do
+ let dockerArgs = ["ps", "--filter", "ancestor=agent-base", "--filter", "ancestor=agent-git", "--filter", "ancestor=agent-haskell", "--format", "{{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Command}}"]
+ Process.callProcess "docker" dockerArgs
+
+-- | Show logs for a container
+showLogs :: String -> IO ()
+showLogs runId = do
+ Process.callProcess "docker" ["logs", runId]
+
+-- | Stop a container
+stopContainer :: String -> IO ()
+stopContainer runId = do
+ Process.callProcess "docker" ["stop", runId]
+
+-- | List available images
+listImages :: IO ()
+listImages = do
+ TextIO.putStrLn "Available toolchain images:"
+ TextIO.putStrLn ""
+ -- Check which images exist
+ forM_ (["agent-base", "agent-git", "agent-haskell"] :: [String]) <| \img -> do
+ (code, _, _) <- Process.readProcessWithExitCode "docker" ["image", "inspect", img] ""
+ let status = if code == Exit.ExitSuccess then "✓" else "✗ (not built)" :: String
+ putStrLn <| " " <> img <> ": " <> status
+ TextIO.putStrLn ""
+ TextIO.putStrLn "Build with: bild Omni/Agentd/Images/<Name>.nix"
+ TextIO.putStrLn "Load with: docker load < _/nix/Omni/Agentd/Images/<Name>.nix"
+
+-- | Main entry point
+main :: IO ()
+main = do
+ args <- Docopt.parseArgsOrExit help =<< Env.getArgs
+
+ if args `Cli.has` Docopt.command "test"
+ then Test.run test
+ else
+ if args `Cli.has` Docopt.command "run"
+ then do
+ let specPath = Docopt.getArg args (Docopt.argument "spec")
+ verbose = args `Cli.has` Docopt.longOption "verbose"
+ case specPath of
+ Nothing -> do
+ TextIO.hPutStrLn stderr "Error: spec path required"
+ Exit.exitWith (Exit.ExitFailure 1)
+ Just path -> do
+ content <- TextIO.readFile path
+ case parseSpec content of
+ Left err -> do
+ TextIO.hPutStrLn stderr <| "Error parsing spec: " <> err
+ Exit.exitWith (Exit.ExitFailure 1)
+ Right spec -> do
+ code <- runWorkflow spec verbose
+ Exit.exitWith code
+ else
+ if args `Cli.has` Docopt.command "ps"
+ then listRunning
+ else
+ if args `Cli.has` Docopt.command "logs"
+ then do
+ let runId = Docopt.getArg args (Docopt.argument "run-id")
+ case runId of
+ Nothing -> TextIO.hPutStrLn stderr "Error: run-id required"
+ Just rid -> showLogs rid
+ else
+ if args `Cli.has` Docopt.command "stop"
+ then do
+ let runId = Docopt.getArg args (Docopt.argument "run-id")
+ case runId of
+ Nothing -> TextIO.hPutStrLn stderr "Error: run-id required"
+ Just rid -> stopContainer rid
+ else
+ if args `Cli.has` Docopt.command "images"
+ then listImages
+ else Docopt.exitWithUsage help
+
+-- | Tests
+test :: Test.Tree
+test =
+ Test.group
+ "Omni.Agentd"
+ [ Test.unit "parse spec" <| do
+ let content =
+ Text.unlines
+ [ "---",
+ "toolchain: base",
+ "workspace: .",
+ "---",
+ "Do something."
+ ]
+ case parseSpec content of
+ Left err -> Test.assertFailure <| Text.unpack err
+ Right spec -> do
+ "base" Test.@=? specToolchain spec
+ "." Test.@=? specWorkspace spec
+ "Do something." Test.@=? specTask spec,
+ Test.unit "parse spec with options" <| do
+ let content =
+ Text.unlines
+ [ "---",
+ "toolchain: haskell",
+ "workspace: ./src",
+ "model: claude-sonnet-4",
+ "provider: anthropic",
+ "max_cost_cents: 200",
+ "max_iterations: 100",
+ "---",
+ "Build the thing.",
+ "",
+ "With multiple lines."
+ ]
+ case parseSpec content of
+ Left err -> Test.assertFailure <| Text.unpack err
+ Right spec -> do
+ "haskell" Test.@=? specToolchain spec
+ "./src" Test.@=? specWorkspace spec
+ Just "claude-sonnet-4" Test.@=? specModel spec
+ Just "anthropic" Test.@=? specProvider spec
+ Just 200 Test.@=? specMaxCostCents spec
+ Just 100 Test.@=? specMaxIterations spec
+ True Test.@=? ("multiple lines" `Text.isInfixOf` specTask spec),
+ Test.unit "parse spec missing frontmatter" <| do
+ let content = "Just some text without frontmatter"
+ case parseSpec content of
+ Left _ -> pure ()
+ Right _ -> Test.assertFailure "Should have failed"
+ ]