Monty is a small OCaml launcher for running a head pi session that plans work and spins out worker pi sessions.
Ghostty tabs, windows, and splits remain the default execution surface.
An optional head-butler workflow can instead run workers through the harness's existing Pi subagent tool without opening Ghostty.
The model does the planning.
Monty handles the glue around Ghostty, wt, worktrees, manifests, Pi subagents, and pi startup commands.
- OCaml with Dune 3.20 or newer.
- Dune package management, with dependencies declared in
dune-project. - No opam files are used by this repo.
pionPATH.- The
pi-subagentspackage enabled when using headless chains. wtonPATH.- Ghostty on macOS for normal launching.
ghonPATHwhen using GitHub issues as a project task source.
dune buildThe repo includes a dune.lock generated by Dune package management.
If dependencies change, refresh it with:
dune pkg lock
dune buildInstall the monty CLI without opam:
./install.shThe installer builds with Dune, copies the Monty control room to ~/.local/share/monty, installs the real binary at ~/.local/libexec/monty/monty-real, and writes a wrapper at ~/.local/bin/monty.
Control-room, binary, and wrapper activation is rollback-protected, and matching .monty state is preserved across reinstallations.
The repository and installer currently use Monty state version 1, stored in .monty/version.
Existing unversioned state is adopted as version 1 without deleting it.
If an existing version does not match, the installer warns that it will delete the current .monty folder and requires interactive confirmation or the explicit --replace-state option.
It also writes MONTY_HOME=~/.local/share/monty and MONTY_BRANCH_PREFIX=monty to your shell startup file, such as ~/.zshrc.
If that file already has a non-Monty-managed MONTY_HOME setting, the installer asks before overriding it.
The wrapper pins MONTY_HOME and MONTY_BRANCH_PREFIX on every invocation, so running monty from any directory starts pi in the installed Monty control room and uses the configured worker branch prefix.
Use another prefix with:
./install.sh --prefix /path/to/prefixUse another Monty home with:
./install.sh --monty-home /path/to/monty-homeSet your worker branch prefix with:
./install.sh --branch-prefix ctoFor development, keep the control room in the current checkout and only install the CLI wrapper and binary:
./install.sh --dev-install --branch-prefix ctoPreview the commands without changing anything:
./install.sh --dry-runFrom this repo, run:
dune exec -- montyAfter installation, run:
montyThis changes to the Monty control-room directory and runs:
pi --name "Monty Head Butler"dune exec -- monty launch \
--repo /path/to/repo \
--title "Fix issue 123" \
--context .monty/runs/run-1/issue-123.mdBy default Monty creates or reuses a repo-scoped worktree for the requested branch.
Monty asks wt, validates that the returned worktree belongs to the requested repo, and automatically answers wt's repo-selection prompt when multiple repos have the same branch name.
The generated worker launch script reruns monty ensure-worktree --repo <repo> --branch <branch> when the terminal starts, so the correct worktree can be recreated if it was deleted after launch.
Durable worker memory lives under Monty home, not in the wt worktree.
The worker receives both Monty instructions and the context file as pi @file arguments.
dune exec -- monty launch-many --manifest .monty/runs/run-1/jobs.jsonExample manifest:
{
"jobs": [
{
"id": "issue-123",
"title": "Fix issue 123",
"repo": "/Users/cristea/code/example",
"branch": "monty/issue-123",
"context": ".monty/runs/run-1/issue-123.md",
"worker_dir": ".monty/runs/run-1/workers/issue-123",
"task_key": "local:local-001"
}
]
}The manifest can also omit branch.
Monty then derives a safe branch name from the title using the branch prefix.
The default is monty, so Fix issue 123 becomes monty/fix-issue-123 for one launch or monty/01-fix-issue-123 in launch-many.
With --branch-prefix cto or MONTY_BRANCH_PREFIX=cto, the same task becomes cto/fix-issue-123 or cto/01-fix-issue-123.
A manifest entry can include task_key to link a worker to a Monty-owned local task.
When monty done archives that worker, it also marks the linked local task done.
Ordinary launch and reconciliation use only explicit task keys and stable repo, branch, and worker identities.
Use monty tasks repair-worker <worker> for an explicit, ambiguity-checked repair of a legacy worker that has no stable link.
Monty validates the complete manifest before creating a task, reserving worker memory, invoking wt, writing a launch script, or requesting a terminal.
Preflight rejects missing repositories or contexts, unknown projects or tasks, unsafe paths, missing dependencies, existing-state conflicts, and duplicate worker IDs, worker directories, task links, or repo-plus-branch identities.
Dry-run and real launch use the same preflight.
A valid dry-run remains mutation-free.
Real launch reserves every worker under the Monty state lock before external work begins.
A runtime failure reports every manifest job as launch-requested, launch-failed, or unattempted/prepared and prints exact recovery commands.
Rerunning the printed batch command safely continues prepared and definitely failed jobs.
It skips launch-requested jobs because another automatic request could duplicate a session.
Use the printed monty resume <worker-id> command when you intentionally want another request for such a worker.
Headless execution is an explicit alternative for the head-butler session.
Ordinary launch, launch-many, and resume commands continue to use Ghostty by default.
Monty does not install a Pi extension for this workflow.
Instead, it generates complete JSON arguments for the harness's existing subagent tool.
The head-butler workflow is:
- Confirm that the current harness exposes the
subagenttool before mutating Monty state. - Run a complete mutation-free preflight.
- Reserve the batch and materialize every repo-scoped
wtworktree. - Run
headless beginfor one prepared worker immediately before invoking the generated harness call. - Pass
harness_call.argumentsdirectly to the harness'ssubagenttool.
monty headless prepare-many \
--manifest .monty/runs/run-1/jobs.json \
--dry-run
monty headless prepare-many \
--manifest .monty/runs/run-1/jobs.json
monty headless begin issue-123headless begin emits the versioned monty:headless-dispatch:v2 envelope.
Its harness_call.tool is subagent, and harness_call.arguments contains the complete asynchronous chain.
No prompt needs to reconstruct the chain manually.
Each generated chain runs in its supplied Monty worktree:
- One fresh implementer changes the worktree and runs focused validation.
- Two mutually isolated fresh reviewers inspect the same worktree concurrently and write only their separate reports outside it.
- One fresh fixer verifies both reports, fixes valid findings, reruns affected validation, and records the final handoff in durable worker memory.
The generated arguments do not request Pi-managed worktrees and explicitly disable progress-file side effects and structured acceptance policy.
All step outputs use absolute paths under the worker's durable artifacts/headless/ directory.
Child prompts prohibit staging, commits, pushes, pull requests, review comments, other remote writes, worktree management, and automatic monty done calls.
Successful chains leave the task open, preserve the worktree, and require the normal explicit monty done <worker-id> lifecycle command later.
Headless dispatch uses a conservative two-phase protocol.
Preparation reserves all identities and records their worktrees while every job remains prepared.
A preparation or other pre-dispatch failure remains retryable as prepared.
Immediately before the harness tool call, begin atomically changes only that worker to launch-requested and emits its exact tool arguments.
A failure after that point remains ambiguous and is never replayed automatically.
When a fresh successor chain is intentional, run:
monty headless resume issue-123The successor envelope also contains the complete harness call.
Monty does not persist a backend, Pi run ID, async status, or subagent runtime state in job.json.
Each launched worker gets a durable memory directory.
For launch-many, the default is:
.monty/runs/<run-id>/workers/<worker-id>/
Monty writes:
job.json
MONTY.md
memory.md
artifacts/
Workers are instructed to write important discoveries, blockers, and handoff notes back to this folder.
The wt worktree is treated as ephemeral and can be recreated from the durable repo and branch in job.json.
Resume a worker by id, branch leaf, branch, or title slug:
dune exec -- monty resume issue-123resume reads job.json, recreates or reuses the repo-scoped worktree, and opens a new pi session with the same durable worker memory.
Resume always derives worktree mode from the durable record rather than a current CLI default, so a never worker cannot accidentally create an unmanaged worktree.
Open jobs are found from worker job.json files.
The original jobs.json manifest is launch input and a safe batch-retry contract.
Durable job.json remains the lifecycle source of truth.
done archives the worker and closes the linked Monty-owned local task in the same command.
Open launch states have conservative meanings.
preparedmeans the durable identity is reserved and no terminal request has been made.launch-requestedmeans a terminal request was persisted before the external call, so automatic replay must not risk a duplicate.launch-failedmeans launch preparation definitely failed before a terminal request and is safe to retry.
A nonzero terminal command after launch-requested remains ambiguous because Ghostty may have created a surface before a later AppleScript step failed.
Monty preserves launch-requested in that case and requires explicit resume.
- Legacy
activerecords remain open and resumable, but the value is not proof of process liveness.
The active path is always .monty/runs/<run-id>/workers/<worker-id>/job.json.
The archive path is always .monty/runs/<run-id>/archive/<worker-id>/job.json.
Physical location determines active versus archived identity.
Persisted path fields cannot override the canonical physical location.
Resume an archived worker and move it back to active memory with:
dune exec -- monty resume --archived issue-123List active jobs from durable job.json files:
dune exec -- monty listList archived or all jobs with:
dune exec -- monty list --archived
dune exec -- monty list --allFilter a run by directory name or path with:
dune exec -- monty list --run run-1Mark a job done with:
dune exec -- monty done issue-123Inside a worker session, monty done uses MONTY_WORKER_DIR and does not need an argument.
done refuses to archive dirty worktrees by default.
Use --force to discard local worktree changes while archiving:
dune exec -- monty done issue-123 --forceCompleting a job deletes the selected repo's worktree and branch, writes status: done to job.json, and moves durable memory to:
.monty/runs/<run-id>/archive/<worker-id>/
Completion and archived resume are recoverable phase workflows.
Monty persists completing or reopening intent before moving state, updating the linked local task, and finalizing status.
A retry can continue from either canonical physical location.
The original --force decision is persisted for the whole completion retry.
monty doctor reports an exact recovery command for every incomplete lifecycle transition.
Monty can keep a small overview of projects and task sources. Project IDs are stable persisted identities derived from repo names, with deterministic disambiguation when needed. External task sources, such as GitHub issues, refresh local external metadata such as title and URL. The local task registry always owns user-facing open or done status, even when a remote issue changes state. Monty-owned local tasks are the task inventory for both external and local work.
Add a project with optional GitHub issues as its task source:
dune exec -- monty projects add \
--repo /path/to/repo \
--github owner/repo \
--query "is:open"List or show project memory:
dune exec -- monty projects list
dune exec -- monty projects show montyShow a cross-project overview:
dune exec -- monty overviewList tasks:
dune exec -- monty list
dune exec -- monty tasks list
dune exec -- monty tasks list --project montylist and tasks list are equivalent task-listing views and show ID, PROJECT, STATUS, TITLE, and BRANCH.
They automatically reconcile worker jobs into the local task source of truth before rendering.
Worker jobs are linked back to local tasks with exact task_key values and stable repo-plus-branch-plus-worker identities.
Reconciliation is sorted and replay-safe.
It writes tasks before patching job links, so interruption can be retried without creating duplicate tasks.
Corrupt or unknown neighboring workers produce diagnostics while healthy records continue.
Use --no-sync with either inventory command for a read-only view with no reconciliation writes or external metadata fetches.
You can run dune exec -- monty tasks sync explicitly when repairing or inspecting sync behavior.
Add or complete a local task:
dune exec -- monty task add --project monty --title "Design overview"
dune exec -- monty task done local-001Monty stores project overview state under:
.monty/projects.json
.monty/projects/<project-id>.md
.monty/tasks.local.json
Use dry-run mode to inspect what Monty would do without creating tasks, worker reservations, launch scripts, worktrees, or Ghostty requests. Dry-run performs the same complete path, identity, project, task, state-conflict, and dependency validation as real launch.
dune exec -- monty launch-many \
--terminal dry-run \
--manifest .monty/runs/run-1/jobs.jsonThe most important options are available as CLI flags.
--terminal ghostty|dry-run
--target tab|window|split
--worktree always|never
--branch-prefix PREFIX
--pi-command COMMAND
--wt-command COMMAND
--fork SESSION
--home DIREnvironment defaults are also supported.
MONTY_TERMINAL=ghostty
MONTY_TARGET=tab
MONTY_WORKTREE=always
MONTY_BRANCH_PREFIX=cto
MONTY_PI_COMMAND=pi
MONTY_WT_COMMAND=wt
MONTY_HOME=/path/to/montydune exec -- monty doctorDoctor prints a stable table with PASS, WARN, and FAIL levels plus exact recovery commands.
The configured pi command is required.
The configured wt command is required only when worktree mode is always.
Ghostty and osascript are required only for the Ghostty backend.
Optional integrations such as gh and sdef produce warnings when unavailable.
Doctor also inspects durable worker state for corruption, incomplete lifecycle transitions, and pending launch states.
It exits zero when checks contain only PASS and WARN, and exits nonzero when any FAIL is present.
Monty stores durable state as plain JSON and Markdown under the configured home.
Every mutation uses one advisory lock at .monty/state.lock.
The lock protects only short local read-plan-write phases and is never held around gh, wt, Ghostty, pi, osascript, git, or other slow external commands.
JSON updates use a same-directory temporary file, file fsync, atomic rename, and parent-directory fsync.
Canonical path validation rejects traversal, unsafe components, symlink escapes, and persisted path metadata that disagrees with physical state.
Unsafe legacy records require explicit repair and are never silently migrated.
Launch-state changes use locked compare-and-update and refuse to overwrite a concurrent completion or reopening transition.
Persisted launch-script paths are accepted only when the complete script bytes prove ownership or an absent destination remains under an explicitly trusted script root.
Scripts are published through same-directory atomic replacement so a destination symlink swap cannot redirect the write.
The checkout-binary E2E suite uses a unique temporary MONTY_HOME for every scenario.
It installs fake wt, gh, pi, Ghostty, osascript, and related tools, and uses real temporary Git repositories when repository identity matters.
The suite covers concurrent mutation, atomic faults, canonical paths, lifecycle recovery, deterministic reconciliation, whole-batch preflight, partial launch recovery, the headless two-phase protocol, generated harness tool arguments, clean-context chain construction, parser behavior, and doctor exit contracts.
Run the complete validation with:
dune build @all
dune runtest --force
git diff --check