PumaBot's core agent is a Pantograph workflow that carries out user objectives. It needs two things to be useful: (1) the ability to run CLI tools like Claude Code in managed terminal sessions, and (2) a GUI for users to submit tasks. Every capability must be API-accessible so the core agent can use it programmatically. The terminal system also needs push-based notifications for idle detection and command completion.
No terminal infrastructure exists today — no PTY library, no xterm.js, no terminal-related modules.
lib/
├── puma_bot/
│ └── terminal/
│ ├── supervisor.ex # Supervisor: Registry + DynamicSupervisor
│ ├── session.ex # GenServer per PTY (ExPTY wrapper, idle detection, PubSub)
│ └── api.ex # Public API for core agent (create, read, write, list, subscribe)
│
├── puma_bot_web/
│ ├── live/
│ │ ├── terminal_live.ex # LiveView: tiled/tabbed terminal GUI
│ │ └── task_live.ex # LiveView: task submission + agent status
│ └── components/
│ └── layouts/
│ └── root.html.heex # Add xterm.js CDN script tag
│
priv/static/assets/
├── js/app.js # Add XTerm + TerminalLayout hooks
└── css/app.css # Terminal grid/tab styling
File: mix.exs
- Add
{:ex_pty, "~> 0.7"}to deps
File: lib/puma_bot/terminal/supervisor.ex
Follow the exact pattern from PumaBot.Workflow.Supervisor (workflow/supervisor.ex):
children = [
{Registry, keys: :unique, name: PumaBot.Terminal.Registry},
{DynamicSupervisor, name: PumaBot.Terminal.SessionSupervisor, strategy: :one_for_one}
]
Supervisor.init(children, strategy: :one_for_all)File: lib/puma_bot/terminal/session.ex
GenServer wrapping ExPTY. Follow the pattern from PumaBot.Workflow.Session (workflow/session.ex) for Registry-based naming, DynamicSupervisor start, and PubSub broadcasting.
State struct:
defstruct [:id, :pty, :command, :args, :cols, :rows, :cwd,
:scrollback, :last_activity_at, :idle_timer_ref,
status: :running, idle_timeout_ms: 30_000]Key behaviors:
init/1: Spawn PTY viaExPTY.spawn(command, args, opts). Store self() PID for on_data/on_exit callbacks. Start idle detection timer viaProcess.send_after(self(), :check_idle, idle_timeout_ms).handle_info({:pty_data, data}): Append to scrollback buffer (capped at configurable max, e.g. 100KB). Broadcast{:terminal_output, id, data}via PubSub to"terminal:#{id}". Resetlast_activity_at.handle_info({:pty_exit, exit_code}): Setstatus: :exited. Broadcast{:terminal_exit, id, exit_code}.handle_info(:check_idle): IfSystem.monotonic_time(:millisecond) - last_activity_at > idle_timeout_msandstatus == :running, broadcast{:terminal_idle, id}. Reschedule timer.handle_cast({:write, data}): CallExPTY.write(pty, data). Resetlast_activity_at.handle_cast({:resize, cols, rows}): CallExPTY.resize(pty, rows, cols). Update state.handle_call(:read_buffer, ...): Return current scrollback buffer contents.terminate/2: Kill the PTY process if still running.
PubSub events (all broadcast to "terminal:#{id}"):
{:terminal_output, id, binary_data}— new output chunk{:terminal_idle, id}— no output foridle_timeout_ms{:terminal_exit, id, exit_code}— process exited
File: lib/puma_bot/terminal/api.ex
Public API module the core agent calls. Thin wrapper over Session GenServer calls:
def create(command, args \\ [], opts \\ []) # -> {:ok, id} | {:error, reason}
def write(id, data) # -> :ok
def read(id) # -> {:ok, buffer} | {:error, :not_found}
def resize(id, cols, rows) # -> :ok
def subscribe(id) # -> :ok (PubSub subscribe)
def list() # -> [%{id, command, status, ...}]
def stop(id) # -> :ok
def get_status(id) # -> %{id, command, status, cols, rows, ...}All lookups via {:via, Registry, {PumaBot.Terminal.Registry, id}}.
File: lib/puma_bot/application.ex
Add PumaBot.Terminal.Supervisor to children list (after PumaBot.Workflow.Supervisor, before PumaBotWeb.Endpoint).
File: lib/puma_bot_web/components/layouts/root.html.heex
Add CDN script tags (matching the existing CDN pattern for daisyUI/Tailwind/QRCode):
<!-- Terminal emulator -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
<script defer src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>File: priv/static/assets/js/app.js
Add Hooks.XTerm hook:
mounted(): CreateTerminalinstance +FitAddon. Open terminal in the hook element. CallfitAddon.fit(). Listen forterminal_outputpush events ->term.write(atob(data)). Attachterm.onData(data => pushEvent("terminal_input", {id, data})). AttachResizeObserver->fitAddon.fit()thenpushEvent("terminal_resize", {id, cols, rows}).destroyed(): Dispose terminal instance.
File: priv/static/assets/css/app.css
Minimal CSS for terminal grid layout. Use CSS Grid for tiled layout. DaisyUI tabs for tab switching.
File: lib/puma_bot_web/live/terminal_live.ex
State assigns:
%{
terminals: %{}, # id => %{command, status, tab_name}
active_terminal: nil, # id of focused terminal
layout: :tabs, # :tabs | :tiled
new_command: "" # input field value
}Key behaviors:
mount/3: List existing sessions viaTerminal.Api.list(). Subscribe to each. Assign state.handle_event("create_terminal", params): CallTerminal.Api.create(command, args, opts). Subscribe to PubSub. Add to assigns.handle_event("terminal_input", %{"id" => id, "data" => data}): CallTerminal.Api.write(id, data).handle_event("terminal_resize", %{"id" => id, "cols" => c, "rows" => r}): CallTerminal.Api.resize(id, c, r).handle_event("switch_tab", %{"id" => id}): Updateactive_terminal.handle_event("close_terminal", %{"id" => id}): CallTerminal.Api.stop(id). Remove from assigns.handle_event("toggle_layout", _): Toggle between:tabsand:tiled.handle_info({:terminal_output, id, data}): Push event"terminal_output:#{id}"with base64 data -> XTerm hook renders it.handle_info({:terminal_exit, id, code}): Update terminal status. Show notification.handle_info({:terminal_idle, id}): Show idle indicator badge on tab.
Template structure:
+--------------------------------------------------+
| [+ New Terminal] [Tabs | Tiled] | <- toolbar
+--------------------------------------------------+
| [bash] [claude] [mix test] [x] | <- tab bar (when layout=:tabs)
+--------------------------------------------------+
| |
| <div id="xterm-{id}" phx-hook="XTerm" | <- xterm.js container
| phx-update="ignore" |
| data-terminal-id={id}> |
| </div> |
| |
+--------------------------------------------------+
For tiled layout: CSS grid with equal columns per terminal (2-col for 2-3 terminals, etc).
File: lib/puma_bot_web/router.ex
Add inside the existing scope "/", PumaBotWeb block:
live "/terminals", TerminalLive, :indexFile: lib/puma_bot_web/live/task_live.ex
Simple interface for submitting objectives to the core agent. Since the core Pantograph agent isn't built yet, this creates the UI scaffold and the message pipeline:
State assigns:
%{
task_input: "", # textarea value
tasks: [], # list of submitted tasks with status
agent_status: :idle # :idle | :working | :error
}Key behaviors:
handle_event("submit_task", %{"task" => text}): Broadcast task to PubSub topic"agent:tasks". Add to tasks list with:pendingstatus. (The core agent, when built, subscribes to this topic.)handle_info({:task_update, task_id, status}): Update task status in assigns.
Template: Text area input + submit button + scrollable task history with status badges.
File: lib/puma_bot_web/router.ex
live "/tasks", TaskLive, :indexFile: lib/puma_bot_web/components/layouts.ex
Update the app/1 function's header to include navigation links to all pages:
- Chats (
/) - Terminals (
/terminals) - Tasks (
/tasks)
Replace the default Phoenix links (Website, GitHub, Get Started) with PumaBot navigation.
lib/puma_bot/terminal/supervisor.ex— Supervisor (Registry + DynamicSupervisor)lib/puma_bot/terminal/session.ex— GenServer per PTYlib/puma_bot/terminal/api.ex— Public API for core agentlib/puma_bot_web/live/terminal_live.ex— Terminal dashboard LiveViewlib/puma_bot_web/live/task_live.ex— Task submission LiveView
mix.exs— Add{:ex_pty, "~> 0.7"}lib/puma_bot/application.ex— AddPumaBot.Terminal.Supervisorlib/puma_bot_web/components/layouts/root.html.heex— Add xterm.js CDNlib/puma_bot_web/components/layouts.ex— Add navigation linkslib/puma_bot_web/router.ex— Add/terminalsand/tasksroutespriv/static/assets/js/app.js— Add XTerm hookpriv/static/assets/css/app.css— Add terminal grid styles
mix.exs— add ex_pty dep, runmix deps.getterminal/supervisor.ex— supervision treeterminal/session.ex— GenServer + ExPTY + idle detectionterminal/api.ex— public APIapplication.ex— wire supervisor- Compile and smoke test backend via IEx
root.html.heex— xterm.js CDNapp.js— XTerm hookapp.css— terminal grid stylesterminal_live.ex— terminal dashboardrouter.ex— add routestask_live.ex— task submissionlayouts.ex— navigation- Compile and end-to-end test in browser
- Compile:
./launcher.sh compilepasses with zero warnings - Backend smoke test (IEx):
{:ok, id} = PumaBot.Terminal.Api.create("bash", ["-l"]) PumaBot.Terminal.Api.subscribe(id) PumaBot.Terminal.Api.write(id, "echo hello\n") # Should receive {:terminal_output, ^id, data} with "hello" in it PumaBot.Terminal.Api.stop(id)
- Idle detection: Create session, wait 30s without input, verify
{:terminal_idle, id}broadcast received - Browser test: Navigate to
/terminals, create a terminal, type commands, see output rendered in xterm.js - Tiled layout: Create 2+ terminals, toggle to tiled view, verify both render and accept input
- Task submission: Navigate to
/tasks, type an objective, submit, see it in task list - Navigation: All nav links work between Chats, Terminals, Tasks