(authored by agents unless marked 🧑)
one program for agents and managers; usage lives in amh --help, not here
- 🧑 "do a port and replace things part by part ... at the beginning use py03 to call everything and only have the Rust part do command line parsing ... Test in production"
- all Rust (
src/), one binary, no interpreter; the port from Python is finishedsrc/cli.rs: the command tree with every help text (clap)src/main.rs: parses the command line into one JSON object (group,action, the values), logs it, runs it, exits with its statussrc/actions.rs: runs the action; a refusal is one lineamh: ...on standard error and exit status 1- how it was ported: one part at a time, each left in production for a while before the next; settings -> task files and task list -> todo/task actions -> agents -> mail -> watcher and guest mailbox -> the remaining actions
- the human's earlier design choices:
docs/design-choices.md - the human's architecture vision (state model apart from input/output, simulation, task lifecycle and dependencies, events, messaging, boundaries):
docs/vision.md - rule taken from it for every part: pure functions over plain data in one module, input/output in a thin caller (
src/taskfile.rsis the pattern)- pure:
taskfile(but its atomicwrite),tasks,work,decide,guest, and the text functions ofmailandomnigent - input/output:
actions,agents,store,settings,omnigent,mail,watch,view,log
- pure:
- build:
cargo build --release; install:cargo install --path ., thenamh manager watchersso the watcher runs the new binary - watcher:
amh manager watchin the foreground;amh-watch.serviceis the user systemd unit for it use-amh: stub that old command names link to- test:
cargo test- the pure decisions on small made-up inputs: what each action changes in a task record or prints, the refusals, the texts, the tree, closing, replacing a manager, mail threads and building and parsing, the watcher, the guest mailbox, starting agents
- no fake Omnigent or mail server: input and output is tested in production
- before deploying a change, try it on a copy that touches nothing live:
AMH_STATE_DIR= a temporary folder holding a copy ofenv.tomlwhosework_logsis a temporary root, whosemail.human_addressis the agent's own address, and without[guest]- there, compare the old and the new binary on throwaway tasks: same output, exit status, and resulting files
- the watcher: run it once there with made-up agent addresses and no mail login
records, all under the work-log root (work_logs in env.toml)
- task file
NAME.md- frontmatter:
statusrunatmanageratis_managertoolpending_task_itemsrunat: agent addressomnigent://ID, orretiredstatus:runninglong_runningblocked(+blocked_on)done- item starting
🧑= the human's request
- body: free text; whole-line
(notes);(pending)blocks
- frontmatter:
TODO.md: sectionscurrent:human pending:low priority:previous:, rowsNAME.md ADDRESSmanager_mail/PREFIX-UID.txt: stored human email,Subject:line then body- name of a task file without
.md= tag in email subjects[tag]
modules
- Zulip (
src/zulip.rs): human updates, uploads, task-topic replies, replacement deletion, and retained email migration aliases- transport settings and flow:
docs/zulip.md
- transport settings and flow:
- settings (
src/settings.rs)- 🧑 "Separate config file, which should live in .config or something and get committed, and env, which should be in local state or something and include things like where the work_logs is"
config.tomlin the config folder ($AMH_CFG_DIR, else$XDG_CONFIG_HOME/amh, else~/.config/amh): same on every machine, committed; optional[launch.TOOL]model,effort,manager_model,manager_effort[compress]task(who is told to compress the human's mailbox; unset = off),unread_over(default 16),unread_urgent(default 30)
env.tomlin the state folder ($AMH_STATE_DIR, else$XDG_STATE_HOME/amh, else~/.local/state/amh): this machine only, never committed; requiredwork_logs,main_manager,[omnigent] url token,[mail] agent_address agent_password human_address human_mailbox_config stored_prefix,[guest] address name workdir stored_prefix
- both files flatten to dotted keys (
mail.agent_address);env.tomlwins - the state folder also holds what the program records: the lock, the watcher log, and the
amh-*.txtrecords
- task files and task list (
src/taskfile.rs): parse and render task files without a YAML library, find(pending)blocks, mark items, place a task's row inTODO.md, atomic file replace keeping permissions- checked against the previous Python on every file of the live root: same parse, render, blocks, and placement
- actions (
src/actions.rs):rundoes every action's input and output: settings, the lock, loading and saving, the caller's own task, files and standard input, the agents, the mail, what is printed- output goes out as it is made (
tell humanprints the sent Message-ID before it checks for unread mail); a reader that went away (amh agent list | head) is not an error - a text file (
--file,--prompt) and standard input are read with\r\nand\ras\n manager watchersrestarts the unit and shows its status;manager worktreeprintsgit status --porcelainof~/.configand of the work-log roottell guestruns here too- unlike the earlier Python, whose output and exit status the actions otherwise keep
- a failure Python showed as a traceback (a file that cannot be read, mail that cannot be reached or sent, a task file that does not parse) is one line
amh: ...; the exit status is 1 as before manager rotaterefuses when the old session lacks the tool, model, effort, or folder and no option gives it; before, the textNonewas passed onmanager watchersexits 1 whensystemctlis killed by a signal; before, 0- a subject in the unread listings is quoted as the watcher log quotes (
decide::quoted): control and invisible characters other than\n,\r,\tare shown as they are, not as\x..escapes - a failed
agent viewis logged withexit=1
- a failure Python showed as a traceback (a file that cannot be read, mail that cannot be reached or sent, a task file that does not parse) is one line
- output goes out as it is made (
- todo and task records
- request stages, ownership, dependencies and events:
docs/request-lifecycle.md - entries without task files:
docs/task-list-entries.md - scheduled commands and standing agents:
docs/scheduled-jobs.md src/tasks.rs: theTaskrecord and every change to it as pure functions: add, remove, reword, move an item; note; status;show;checkof task files against the task list; the notice text for the humansrc/store.rs: the work-log root on disk: the lock, loading and saving task files, the task list row, the caller's own task; shared by the actions, the watcher, and starting agents
- request stages, ownership, dependencies and events:
amh agent view WHO(src/view.rs): the human's command-line view; attaches the caller's terminal to the agent's own tmux terminal that Omnigent runs (--read-onlyto only watch); a parked agent has none until a message wakes it- agents (
src/omnigent.rs): every agent is an Omnigent session, reached as JSON over HTTP on the local server- 🧑 "the Rust port would only support Omnigent, not tmux": no tmux launch, paste, or status; an address that is not
omnigent://IDis reported as not reachable send: one message event; a multi-line message is followed by one typed line, because Claude Code treats multi-line input as pasted text and does not act on it aloneclassify: pure;running,ready(idle, also when Omnigent has parked the runner),stuck(the tool waits on an unanswered prompt, so the session reads no messages),error(failed, with the harness error),missinglaunch: needs exactly oneTOOL-native-uiagent and one online host; every tool runs with no sandbox and no permission prompts; waits up to 90 s for the runnerapprove_prompts: answers yes to permission prompts a session waits on; the watcher calls it once a minute forstuckagents and tells the manager; other kinds of prompt staystuckand are reported- 🧑 "Sounds good, but should not be the default, which is no question any time at all"
stop: when Omnigent refuses because the session's terminal has already vanished (no running terminal in its resources), that counts as stoppedstop,saw_message(did the session's history take the message),own_address(the caller's session variable),launch_default(model and effort per tool,[launch.TOOL]inconfig.toml)
- 🧑 "the Rust port would only support Omnigent, not tmux": no tmux launch, paste, or status; an address that is not
- action log (
src/log.rs): everyamhrun appends one line toamh-actions.login the state folder: time, caller (Omnigent session, else tmux pane, else user), exit status, and the parsed command line;agent viewandmanager watchalso get a line when they start - the agents of tasks
src/work.rs, pure:envelope;problem_of(an agent that ismissing,error,stuck, or idle with open items; a blocked or retired task has none; along_runningtask withblocked_onis not idle); the request, the first prompt, and the task record of a started agent;manager_message(tell manager: at most 600 characters, labelled with the task andblocked/done);close;tree;handover,relaunch,repointed(replacing a manager); the rows ofagent list,agent problems,manager worktreesrc/agents.rs, input and output:problems;start_taskandstart_agent(run eachgetagentsmdcommand with a 30 s limit, launch, record under the lock, send the first prompt, wait up to 90 s for it to show in the history);close_task;rotateclose_task, in this order: under the lock mark the task done and move its row toprevious; email the humanClosed ADDRESSon the task's thread, only when the human gave the task a request themselves (requests::from_human; the same rule decides whether a failed or stuck agent is reported to the human besides its manager); for an agent closing itself, print that its session stops and tell its manager; stop the agent- a failed email ends the action with the task done and the agent still running; a failed stop fails the action unless
--agent-gone
- a failed email ends the action with the task done and the agent still running; a failed stop fails the action unless
rotate, in this order: read the old Omnigent session; start a fresh agent on the same task file with the same tool, model, effort, and folder unless options say otherwise; under the lock point the active tasks that reported to the old address at the new one, and for the main manager rewrite its address inenv.toml; stop the old session; print the new address- a session field that is missing and not given as an option is refused before anything starts
- a stopped session reads
readywithrunner_online=False, as a parked one does - unlike the earlier Python:
--linescounts lines asseddoes (line breaks are\n,\r\n,\ronly)
- mail (
src/mail.rs): send on the tag's thread (agent Gmail account), list/trash the human's unread (human mailbox via himalaya config), fetch unread mail from one sender- pure:
bare,tag_of,plan(subject and thread of an outgoing email from the emails found with the tag),compose(the bytes to send),parse(a raw email -> sender, headers, body, images),header_of,replace_reminderandHeader::listed(whattell humanandmail unreadprint) - input and output:
Mailbox(one logged-in IMAP connection:search,unseen_from,mark_read),deliver(SMTP),send,unread - the watcher holds one
Mailboxacross polls and logs in again only after a failure - a stored email is read as Python's text mode read it:
\r\nand\rbecome\n compose+deliverbuild and send every outgoing email, to the human and to the guest- after a send,
tell humanlists the agent's earlier emails the human has not read and tells it to replace them with one email; left out of that list and ofmail unread: item notices and watcher emails (X-AMH-From: amh ...), and emails sent outside amh (noX-AMH-From), such as a mail-compression job's summaries - an email to the human that Gmail refuses (e.g. the account's daily sending limit,
550 5.4.5) is kept inoutbox/in the state folder; the watcher tries the outbox every 10 min, oldest first, and stops at the limit;tell humanthen says the email is queued, and nothing it replaces is trashed - 🧑 "when we make agents add/remove pending items we urge them to do multiple at a time and also attach any other things to be emailed to the human. I don't want any additional latency from debounce"
- 🧑 "hide the parts about pending item created or deleted"
todo add,todo done,task giverecord changes without bookkeeping emailsamh tell human --add ITEM --done ITEMrecords changes and sends only the supplied message
- nothing waits: no record of unreported changes, no later digest
- 🧑 "Get some agent to fix it, and don’t bother me afterwards":
todo done ... --quiet-because WORDSremoves the human's items without the email and notes the human's words in the task file
- 🧑 "hide the parts about pending item created or deleted"
- pure:
- short commands for the human, top level (
src/cli.rs,shortinsrc/actions.rs)- 🧑 "sane short commands for common tasks like spawning agents, without needing tons of flags"
new NAME [GOAL] [-m] [-t TOOL](current directory, Claude, reports to the main manager; editor when GOAL is missing),ls(=agent tree),view NAME [-r],say NAME [TEXT](queued like an email from the human; editor when TEXT is missing),edit NAME,stop NAME(asks once when open items or undelivered messages remain, then clears them and closes),log NAME [-n N](the task's lines from both logs, merged by time)- 🧑 "Make it lenient and allow both having and not having .md and allow full paths.": every value that names a task (
task,who,source,to,task_file) goes throughwork::task_name:NAME,NAME.md, or a path; anomnigent://address stays
- blocked tasks: 🧑 "Make it s.t. agents naturally learn to use --on from running the help page command they would normally run"
- the rule (WHAT is the thing waited for, never just who) and examples are in the help of the
taskgroup, oftask status, and of--on; no instruction file says it
- the rule (WHAT is the thing waited for, never just who) and examples are in the help of the
amh task edit TASK.md(the human): opens$VISUAL/$EDITOR/vion a copy of the task file, likecrontab -e- 🧑 "Should be append-only. If the edit changed other things, offer to reopen the file and ask for fix"
- what was added at the end is stored as
manager_mail/PREFIX-edit-EPOCH.txtand queued as amessagesrow, so it reaches the agent exactly like an email from the human amh tell human --attach PATHattaches files (20 MB in total at most), e.g. a figure the human asked to see- crates:
imap+native-tls(IMAP),lettre(building and SMTP),mail-parser(parsing),mime_guess(attachment type from the file name) - of a repeated header the first counts; the parser's own lookup would take the last, which the sender controls for
Authentication-Results - an HTML-only body is kept as written, not converted to text
- unlike the earlier Python: a found email's date is as written, not rewritten to one format; sent mail carries a
Dateheader and the Message-ID<NANOSECONDS.PID@gmail.com> - checked read-only against the earlier Python on the live mailboxes: same search results, subjects, unread listing, and parse of real emails
- watcher: loop every 5 s
src/decide.rs, pure:route,for_manager;plan(who gets a task's blocks), the message texts,settle(what follows from a deliveryOutcome),without_marker;nudges(reminders and problem reports);chase(unanswered email)src/watch.rs, input and output: the kept mailbox connection (Link), files, agents, the clock, the log; remembers only retry and reminder times between polls- log: one line per event on stdout,
YYYY-MM-DD HH:MM:SS TEXT; an error is shown as its message - records in the
recordstable of the database, named and formatted as the files the Python watcher wrote:amh-awaiting.txt(EPOCH TASK MAIL STAGE),amh-told-human.txt,amh-asked-human.txt,amh-problem-notices.txt,amh-compress.txt; reads the fileamh-sent.txt - checked against the Python watcher on a frozen copy of the live root with the agents and mail replaced: same routing and delivery text of the 200 newest stored emails, same targets for every task file, same messages, log lines, notices, and records for deliveries, problem reports, and chasing
- unlike the Python watcher
- nothing is reminded or reported in a minute in which any agent's state could not be read; before, agents listed ahead of the unreadable one were still reminded
- a failure to store guest mail keeps the mailbox connection; a failure to start the guest agent is logged, the process does not exit
- a failure to write
amh-awaiting.txtis logged and the delivered block's marker is still deleted, so the block is not delivered twice - a malformed row of
amh-awaiting.txtoramh-sent.txtis dropped - stored human mail is created with mode 0600, not created and then restricted
- mail intake: store, route, append the source line and insert a
messagesrow (channelhuman) in one transaction, mark read- route:
[tag]task;for manager/for a manager/for the managerstarting or ending the body -> its manager; done task -> its manager; else main manager - stored file exists = already taken in
- route:
- delivery: each pending
humanrow and each(pending)block (text typed into a task file, guest mail) -> the task's agent; then the row is marked sent or the marker line is deleted, the source line stays- a block in a task with no agent (done or retired) goes to that task's manager, else the main manager
- an idle Omnigent session whose runner is offline is reachable: the next message brings the runner back
- stored email is inlined verbatim in
<human_instruction> - 🧑 "if I message an agent and somehow they are not whatever state they are, I should get some sort of response"
- delivery to the agent fails -> the block goes to its manager, else the main manager, and the human is emailed once that it was rerouted
- nobody reachable -> the human is emailed once; retried after 10 min
- delivered human email is recorded in
amh-awaiting.txt; every email an agent sends is recorded inamh-sent.txt - no email from that task within 15 min -> the agent is reminded; within 30 min -> its manager is told and the human is emailed
- 🧑 "No need to email me for this, just handle it":
amh mail handled MAIL --because WORDStakes the caller's row out ofamh-awaiting.txtand notes the human's words in the task file - human notices are sent once per message and outcome, keys in
amh-told-human.txt
- nudges: idle agent with open items is reminded at most every 30 min; the settings
remind.idle_sand, per tool,remind.TOOL_idle_s(e.g.[remind] codex_idle_s = 300) change that - mailbox compression notice, every 5 min
- 🧑 "do a dedicated agent “human manager” who directly gets notification to compress the mailbox and does it"
- counts the unread emails from the agent account in the human's inbox, without the item notices (read-only)
- more than
unread_over: thecompress.taskagent is messaged with the count and the count per task tag; again hourly while it stays above, and at once when it reachesunread_urgent amh-compress.txtholds the last notice (EPOCH COUNT); it is removed when the count is back at or under the threshold
- agent problems (
missing,error,stuck): each is reported once, naming the task file, tool, address, manager, and the harness error- to the task's manager; for
errorandstuckalso by email to the human on the task's thread - mail for a
missingorstuckagent goes to its manager instead - reported keys live in
amh-problem-notices.txt; a problem that goes away is forgotten, so a recurrence is reported again - nothing is reported while Omnigent is unreachable
- to the task's manager; for
- permission prompts: once a minute a
stuckagent's permission prompts are answered yes and its manager is told
- guest mailbox (
src/guest.rs, pure; the watcher takes the mail in,src/actions.rssends the answer)- mail whose
Fromis exactlyguest.addressand whose firstAuthentication-Resultshasspf=passandsmtp.mailfrom=that address is stored underguest_NAME_manager_mail/with its thread headers and up to 4 images of 10 MB each; other mail from that address is marked read and dropped - a dedicated agent (task
guest_NAME.md, NAME fromguest.name) is started under the main manager or reused; the email becomes its open item and a(pending)block - it answers with
amh tell guest, which sends to the guest alone on the thread of the stored email
- mail whose
dropped on purpose from the old scripts
- report envelopes/receipts:
tell managerposts straight to the manager - recovery modes, v2 task format, audit watcher, mail compression
task record,mail trash-replaced:task give --from-human,tell human --replaces