@miadi/factory-runner
v0.1.5
Published
Opens an agent session for each brief in the Miadi factory queue: the brief format the connector writes, queue delivery, tmux session opening, a launcher per provider and its session-start steps (Remote Control for Claude Code). Runs on Ubuntu and Termux,
Readme
@miadi/factory-runner
An approved run in the Miadi factory becomes a live agent session. Parts of the factory write a
brief into the factory queue: the connector's approval door (@miadi/connector) for an
approved run or work proposal, Miadi's review doors for Deep Diver passes. This package reads the
queue, opens or reaches a tmux session for each brief, starts the agent the brief names, runs its
session-start steps (Remote Control for Claude Code), and hands the brief over.
It is one installed package: no checkout, no path that names a host, a user or a personal workspace. The logic is here. The data it runs on lives in the factory's data folder. jgwill/Miadi#911.
npm install -g @miadi/factory-runner
miadi-factory-runner config # the settings in use and where each came from
miadi-factory-runner deliver --dry # what the next pass would do, moving nothing
miadi-factory-runner deliver # one pass: what the systemd timer runs
miadi-factory-runner watch # a pass every 300 s, for a host without systemd (Termux)Requires Node.js 20 or newer and tmux.
The brief
One file in <factory folder>/queue/, holding one <input …> block:
<input to="new:connector-261101-9e7e2c1f" status="send" type="connector-run" record="rec:9e7e2c1f…" provider="claude" cwd="/srv/work">
the text the session reads
</input>| attribute | meaning |
|---|---|
| to | the tmux session that receives the brief, or new:<name> to open a session with that name (1 to 80 of A-Z a-z 0-9 . _ -) |
| status | send is delivered at once. ready is delivered only through a consumer that passes its own readiness check (deliverFile({ gate })). draft and consult are never delivered |
| type | what wrote the brief: connector-run, deepdive, … |
| record | the connector record the brief carries out |
| cwd | the folder a new session opens in; default defaultCwd |
| provider | the agent a new session starts: claude, codex, pi, copilot, or a launcher named in the config |
| launcher | the exact command a new session starts; wins over provider |
| model | the Claude Code model a new session starts on, passed as --model; one plain word. A brief naming one for another provider or for an existing session is refused and moved to queue/refused/ |
| model_source | who chose it: request, jev, configured_fallback or cli_default, kept in the receipt |
| provider_authorization, provider_choice, provider_policy | written by the connector for a provider that needs its requester's own choice: a type="connector-run" brief starts Codex only with person_choice and a provider_choice:<id>, or policy_selectable and a provider_policy:<id>. Codex reached any other way for such a brief (default provider, alias, launcher=) opens nothing (jgwill/Miadi#926) |
| remote_control | 0 (or false, off, no) starts a new Claude Code session without --remote-control <name> |
Writers build briefs with formatBrief, and pass anything a requester wrote through text()
(attribute values go through attr() inside formatBrief), so no field can close the tag or
open another block:
import { formatBrief, text } from "@miadi/factory-runner/brief";
const brief = formatBrief(
{ to: "new:connector-261101-9e7e2c1f", status: "send", type: "connector-run", provider: "claude" },
["A bounded task requested through the Miadi connector.", "", "<task>", text(task), "</task>"],
);parseBlocks reads blocks back and validateBrief checks one before the runner acts on it.
What a pass does
For each queue file, each block whose status is send:
to="<session>": once that session's Claude Code agent is idle and the pane guard (if configured) is clear, one line is typed asking the session to read the brief file whole first. The brief's words are kept exactly in<briefsDir>/brief-<yymmdd>-<key>.md, so one line never splits into several submissions.to="new:<name>": a tmux session<name>opens incwd. Once the shell shows its prompt, the launcher for the brief's provider is typed. For Claude Code the launch line carries--remote-control <name>, so the person can follow the session from claude.ai or the Claude app under the same name. The runner waits until the agent is idle and its own session record names a Remote Control session (bridgeSessionId), up to 60 s. Only then does it type the line, and it presses Enter until the agent turns busy or the transcript holds the line as a prompt. A line that a slash command took as its arguments is reported as not sent. If Remote Control never connects, nothing is typed and the brief stays in the queue with the reason. Other providers leave no record of taking a prompt: the runner waits up tosettleSecfor the session's steering adapter to announce itself in the pane, sends the line once, and recordssubmitted: "unverified". The receipt'ssession_idandsteeringsay which session the adapter named, or that none did, which is what the connector binds a mission to (@miadi/connectorSTEERING.md).Until 0.1.1 the runner typed
/remote-control <name>into the input box. On 2026-11-04 its Enter was lost, the brief line was typed after it, and Claude Code ran/remote-controlwith the brief as its arguments, while the ledger recorded the brief as sent.
Each block is delivered once. The ledger (delivered.jsonl) is keyed by the block's identity
(file name, opening tag without its status, first body line) and records where it went, the pane,
the session id and the Remote Control session (remote_control.url). Then the file moves:
| folder | when |
|---|---|
| queue/delivered/ | every block in it was sent |
| queue/malformed/ | it holds no <input> block |
| queue/stale/ | it was held longer than staleAfterHours |
A file that cannot be delivered yet stays, and queue/status.json keeps when it was first held,
how often, and why. Each move is one line on stdout. The connector reads these folders to report
a run's state.
Settings
The factory folder is $MIADI_FACTORY_DIR, else /srv/miadi/factory. The config file is
$MIADI_FACTORY_CONFIG, else <factory folder>/factory.json. Every setting below defaults
inside the factory folder. An environment variable wins over the file, which wins over the
default; miadi-factory-runner config shows each value with its source.
| setting | env | default |
|---|---|---|
| queue | (<factory folder>/queue) | /srv/miadi/factory/queue |
| ledger | MIADI_FACTORY_LEDGER | <factory folder>/delivered.jsonl |
| briefsDir | MIADI_FACTORY_BRIEFS | <factory folder>/briefs |
| defaultCwd | MIADI_FACTORY_CWD | <factory folder> |
| defaultProvider | MIADI_FACTORY_PROVIDER | claude |
| launchers | | claude, codex, pi, copilot, each its own command |
| remoteControl | MIADI_CONNECTOR_CLAUDE_REMOTE_CONTROL (0 turns it off) | true |
| paneGuard | MIADI_FACTORY_PANE_GUARD | none: a command run with the pane id, exit 0 meaning nobody is typing |
| staleAfterHours | | 24 |
| readyTimeoutSec | | 180: how long a new Claude Code session has to reach an idle prompt |
Every path must be absolute. The one path outside the factory folder is Claude Code's own record
of its sessions: $CLAUDE_CONFIG_DIR, else ~/.claude of the user the runner runs as. The runner
only reads it (sessions/<pid>.json for idle or busy, projects/*/<session id>.jsonl for a
submitted line). It must be the same user whose Claude Code the launcher starts.
The ledger line of a launch that names its model carries model: { requested, source, flag }, which is what the launch line asked for. It is written at submission and waits for nothing. Which model answered is in the session's own transcript, for a reader such as the connector's mission_status.
A launcher entry is { "command": "…", "agent": "claude" | "other", "steps": ["remote-control"], "settleSec": 10 }.
An entry in the file is merged over the default of the same name, so { "command": "claudeyolochronicle" }
keeps Claude Code's Remote Control step. The step appends --remote-control <name> to the
command, so a Claude Code launcher must pass its arguments on to claude (the claudeyolo*
aliases do). A new name adds a provider.
Example: gaia
/srv/miadi/factory/factory.json:
{
"defaultCwd": "/a/src/Miadi-18",
"launchers": {
"claude": { "command": "claudeyolochronicle" }
},
"paneGuard": "/etc/claude-code/skills/dispatch-discipline/pane-write-guard.sh"
}claudeyolochronicle is a shell alias, which works because the launcher is typed into the new
session's interactive shell.
The connector and the runner must name the same factory folder: the connector writes
<factory folder>/queue/, the runner reads it. On Termux, set MIADI_FACTORY_DIR.
Run it every 5 minutes (systemd user timer)
~/.config/systemd/user/miadi-factory-runner.service:
[Unit]
Description=Miadi factory runner: deliver the factory queue to agent sessions
Documentation=https://github.com/jgwill/Miadi/tree/main/packages/factory-runner
[Service]
Type=oneshot
Environment=MIADI_FACTORY_DIR=/srv/miadi/factory
# Optional: MIADI_CONNECTOR_CLAUDE_REMOTE_CONTROL=0 and other overrides, one KEY=VALUE per line.
EnvironmentFile=-/srv/miadi/factory/runner.env
ExecStart=/usr/bin/env miadi-factory-runner deliver~/.config/systemd/user/miadi-factory-runner.timer:
[Unit]
Description=Every 5 minutes, deliver the Miadi factory queue
[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
Unit=miadi-factory-runner.service
[Install]
WantedBy=timers.targetsystemctl --user daemon-reload
systemctl --user enable --now miadi-factory-runner.timer
journalctl --user -u miadi-factory-runner.service # the QUEUE lines of each passA user unit starts with a short PATH. When miadi-factory-runner or node is not in
/usr/local/bin or /usr/bin (an nvm install, say), put the absolute path of each in the unit:
ExecStart=<command -v node> <command -v miadi-factory-runner> deliver. The user running the
timer needs write access to the factory folder and a tmux it can start sessions in.
A host without systemd (Termux) runs miadi-factory-runner watch --interval 300 in a tmux
session of its own.
As a library
import { deliverFile, deliverQueue, loadConfig } from "@miadi/factory-runner";
const cfg = loadConfig();
await deliverQueue({ cfg }); // the queue, status "send" only
await deliverFile({ file, cfg, gate: myReadinessCheck }); // another source, with its own ruleA gate is (block) => ({ send, why, ...extra }), sync or async; what it adds goes into the ledger
entry. openNew, deliverToExisting, resolveLauncher and remoteControlLine are exported for a
consumer that opens sessions its own way.
Tests
npm testThey run with a fake tmux and a fake Claude Code session record, so they need neither.
