fractal node#

The fractal node sub-app manages individual agent nodes: creating them, launching and signaling their iteration loops, merging and tearing down their branches, inspecting their state, and tracking their time and cost budgets. Tree-wide operations (fractal pause, fractal resume, fractal reset, fractal destroy) live on the top-level CLI — see Top-Level Commands. The lifecycle model behind the verbs is described in Node Lifecycle, and the iteration loop each node runs in The Iteration Loop.

Conventions#

Every command below follows the same shape:

Target selection

Most commands take an optional NODE argument naming the target node’s branch. When omitted, the target is the node at --path (default .), resolved to its git-worktree toplevel. From the repo root, the checkout’s own user node is the target whenever the checked-out branch carries one (the normal case on the tree’s root branch). On a checkout with no user node, the caller’s own node is used when the _NODE environment variable is set (as it is inside a node’s loop); otherwise a single worktree under .worktrees/ resolves implicitly (echoed on stderr), and multiple worktrees refuse with a request to name one.

Short names

Wherever a branch is accepted, a unique trailing segment works: lexer resolves to main.parser.lexer. An ambiguous short name refuses and lists the candidates; an unknown one refuses with No node found for branch: '<name>'.

--path

Every command accepts --path <dir> (default .). It identifies the caller’s worktree — pick the target with the NODE argument, not with --path. The one exception is node init, where --path names the project root (repo root or monorepo sub-project).

Output

Listing commands print a text table on a TTY and CSV automatically when piped; --csv forces CSV either way, and --json (where present) prints a JSON array instead ([] when empty, mutually exclusive with --csv). Empty listings still emit the header row. stdout carries the parseable result; notices, warnings, and confirmations ride stderr. Core refusals print Error: <message> on stderr and exit 2, as do argument errors raised at the CLI boundary (bad values, unknown or ambiguous node names, mutually exclusive flags), which print a usage error (Invalid value: <message>). Exit 1 is reserved for a command’s own nonzero outcome — fractal commit --check on a dirty tree — and for a declined interactive confirmation (delete without --force prints Aborted.). Whatever the cause, the last stderr line of every failed command is FAILED (exit <code>) naming the exit code, so a tail -1 can never mistake a failure for a success.

Values

Numeric caps must be non-negative; unlimited is expressed by omitting the flag, never by a sentinel value. Durations take a unit suffix — <number><s|m|h|d>, e.g. 30s, 10m, 1h — and must be at least one second. Costs are USD amounts printed as $X.XXXX.

The underscore-prefixed commands are internal plumbing invoked by the node’s own scripts; they are not user surface and are hidden from --help.

Creating nodes#

init#

$ fractal node init NAME [options]

Create an agent node: a git worktree on branch <parent>.<name> under .worktrees/, plus a node data directory .fractal/<branch>/ seeded with steps, scripts, skills, and config.json (a template-seeded node also records what seeded it in _template.toml). The node’s task contract lives in <node_dir>/NODE.md — author its Instructions and Completion Requirements sections, then launch with fractal node start.

With a template, supply required inputs before init. Reusable prose and Jinja expressions live in NODE.md, steps, or source-only fragments such as _partials/foundations.md. Jinja includes read only the same committed template bundle. Its optional _template.toml holds literal [values] defaults, while the node’s generated file records the complete resolved inputs and source commit. TOML is parsed as data, without rendering, and metadata is not a standalone output. An explicit Jinja include treats any named bundle file as source.

Jinja supports includes, imports, macros, inheritance, branches, loops, and raw blocks. Source line endings normalize to LF, trailing newlines are retained, and HTML escaping and automatic block whitespace trimming are disabled. Values containing {{...}} remain literal; $VAR remains for runtime rendering. Tables render in recursively sorted key order; lists preserve their supplied order. Only selected outputs render independently, and all rendering completes before deployment.

Rendered step frontmatter must satisfy the step configuration grammar before deployment. Use {{ detached | lower }} or {{ detached | tojson }} for a boolean flag: the step parser requires lowercase true/false.

NAME is one branch segment: letters, digits, and underscores only (no dots, dashes, or slashes), at most 64 characters. A node spawned from inside another node’s worktree nests under that node; otherwise it nests under the node at --path.

Option

Default

Meaning

--path <dir>

.

Project root: repo root or monorepo sub-project.

--title <text>

de-slugged name

Human-readable display name.

--scope <dirs>

unrestricted

Subdirectory scope(s) within the worktree that commits are restricted to (comma-separated; repeatable). Must be repo-relative — absolute or .. paths refuse.

--base <branch>

parent branch

Branch to start from; when set it is also the squash-merge target and must have a checked-out worktree.

--meta <branch>

none

Target node branch for meta-configuration; expands to --base=<target> --scope=<target's seed dir>. Mutually exclusive with --scope/--base. The scope is spelled relative to the meta node’s own project: initialize a meta node for a sub-project target from that target’s worktree or from the repo root — one initialized from a different sub-project is refused.

--inherit <surfaces>

package seed

Seed surfaces from the parent instead of the package seed (comma-separated; repeatable): steps, scripts, skills, config, or all. config copies preference keys only — budget-class caps never inherit.

--template <path[@ref]>

package seed

Template folder: any tracked folder holding a config.json — its presence is what marks the folder as a template. The folder is read at the child’s fork commit, so uncommitted edits never deploy (append @<ref> to read another commit), and recorded — path, commit, and resolved inputs — in the node’s _template.toml. Its surfaces (NODE.md, steps/, scripts/, skills/, agents/) seed the node; a surface it lacks falls back to the inherit-or-package source, and one it carries refuses the matching --inherit. The config.json doubles as a config preset, filling each run-config flag the spawn left unset (a flag wins over the preset; the preset beats an inherited value). Every template refusal — an untracked folder, a symlink, a credential-named file anywhere in the folder, a file a seed surface would silently skip, a broken steps/ contract, missing required Jinja inputs, a truncated charter — fires before any worktree exists. One notice appended to the init output names the @<ref> form when the root branch holds a different copy of the folder.

--include <path>

whole template

Deploy only these template-relative output paths (repeatable; a directory entry covers its subtree; no globs); requires --template and is mutually exclusive with --exclude. The listing is recorded in _template.toml: the effective set — only include, or everything minus exclude — is what init deploys and what diff and reseed judge by, so an excluded step stays gone across every reseed. An entry matching nothing or naming source-only documentation or _partials/ refuses at init. Jinja includes still read source from the full committed template bundle.

--exclude <path>

none

Skip deployment of these template-relative output paths; the mirror image of --include (repeatable, recorded, a directory entry covers its subtree). An excluded output may still be included by another selected output; excluding a file does not remove its text transitively.

--values <file>

none

TOML inputs for seed-time Jinja rendering, overriding the template’s optional _template.toml [values] defaults. The external file names inputs at its top level, without a [values] wrapper; strings, booleans, numbers, lists, tables, and date/time values keep their types. Requires --template; the node’s generated _template.toml records the complete resolved map. Missing inputs used by selected outputs refuse before creation. Values remain literal data, and prompt-time $VAR text passes through untouched.

--set KEY=VALUE

none

One input as a TOML literal (repeatable); wins over --values. Use --set 'role="reviewer"' for text, --set enabled=false for a boolean, or --set 'checks=["tests"]' for a list. Requires --template. Each override replaces the whole top-level value; nested tables are not deep-merged. Names are lowercase identifiers; true, false, none, not, and self are reserved.

--pin <sha>

none

Commission pin: must resolve to a commit, and every pin: line in the template charter must resolve and match it (case-blind — a spelling that is not a hex sha refuses outright); every docket: <path> line must resolve at the pin (--pin when given, else the charter’s own, else the fork commit) — a stale or truncated seed dies at init, not at the commission’s first seat. Also fills {{pin}}, overriding a template default; a pin supplied through --set/--values must agree with --pin, and a differing pair refuses.

--agent <command>

nearest ancestor’s

Agent command, validated against the agent registry (a typo refuses). See Agent Backends.

--provider <route>

inherited, else vendor-native

Provider route (e.g. openrouter). An unsupported explicit route refuses; an inherited unsupported one is silently dropped.

--model <name>

agent’s default

Model override, passed to the agent CLI.

--effort <level>

none

Reasoning-effort override, passed to the agent CLI.

--max-iters <n>

unlimited

Per-run iteration cap; must be greater than 0.

--max-depth <n>

unlimited

Maximum child nesting depth below this node.

--max-children <n>

unlimited

Maximum unsettled direct children (concurrency, not lifetime count).

--max-descendants <n>

unlimited

Maximum unsettled descendants in the subtree.

--timeout <dur>

none

Whole-run time budget (e.g. 30s, 10m, 1h).

--iter-timeout <dur>

none

Per-iteration time budget; defaults to --interval when that is set, and may not exceed it.

--step-timeout <dur>

none

Per-step time budget.

--step-retries <n>

1

Retries per failed step; 0 disables.

--step-retry-backoff <dur>

10s

Delay before each step retry.

--interval <dur>

none

Fixed iteration schedule; mutually exclusive with --sleep.

--sleep <dur>

none

Delay between iterations.

--wait <dur>

1m

Sleep between sync passes while waiting — on a child drain or an approval gate.

--max-cost <usd>

uncapped

Per-run cost ceiling; each launch arms the cap anew.

--max-iter-cost <usd>

none

Per-iteration cost cap; requires --max-cost.

--max-step-cost <usd>

none

Per-step cost cap; requires --max-cost. Warn-only for agents whose CLI has no budget flag.

--reserve-budget <usd|N%>

10% of --max-cost

Budget reserved for cleanup, as USD or a percentage of --max-cost; only meaningful with --max-cost and must stay below 99% of it.

--sync / --no-sync

enabled

Run sync mode (the radio check-in) before each step.

--detached / --no-detached

continuous

Run each step as a separate agent invocation instead of one continuous session.

--local / --no-local

disabled

Skip pushing to remote after each iteration’s commit. Inherited from a local parent and immutable once set.

--blind

disabled

Subscribe to no radio channels (the parent still reads this node). See Radio.

--sealed

disabled

Seal the node’s mailbox: its own seat cannot read hosted messages until an operator or the parent unseals it (config set sealed=false, which the sealed seat itself may not run) — verifier isolation.

--reset

—

Delete node files and reinitialize.

Cost caps must satisfy step <= iter <= run ordering. Spawn caps (max_children, max_descendants, max_depth, and the parent’s remaining budget) are enforced at init, and spawning refuses under a paused ancestor or a tree-wide pause latch. An existing node refuses without --reset, and --reset itself refuses over an active or paused node. One advisory rides stderr without blocking: a node with neither --max-cost nor --max-iters warns that it can run and spend without bound.

$ fractal node init parser --title "Build the parser" --scope src,tests \
      --max-cost 10 --max-iters 10 --timeout 24h
$ $EDITOR .worktrees/main.parser/.fractal/main.parser/NODE.md
$ fractal node start parser

Running nodes#

start#

$ fractal node start [NODE] [--continue] [--clean] [--drain]
      [--max-cost <usd>] [--headless | --tmux]

Launch the node’s iteration loop in a tmux session (named <repo> (<branch>) with dots flattened to dashes) or, with --headless, in a detached process group. Run parameters come from the node’s config.json; only the runtime choice and the continue flags are set at launch time.

--continue

Re-arm a settled node (completed, stopped, exited, or killed) for further iterations. The launch restores the worktree; a dirty worktree refuses and lists the uncommitted project files that would be discarded.

--clean

With --continue: acknowledge discarding uncommitted project files (node-directory files are exempt).

--drain

With --continue: run a drain — the harness forbids spawns and re-arms from this run (node init — a spawn or a whole new tree — plus start/update/resume and the _loop entry they front all refuse from its seats, enforced via the exported _DRAIN and the run’s own recorded drain) and injects the DRAIN mode doc so every seat closes out instead of expanding. The drain’s own relaunch after a park is exempt.

--max-cost <usd>

With --continue: retune the cost cap before relaunch, echoed max_cost: old -> new. Required when the last run ended on its cost budget — runs are isolated and each launch arms the cap anew, so a budget-ended run refuses a bare --continue.

--headless / --tmux

Select the runtime backend: --headless runs the loop in a detached process group, appending its output to the node’s headless.log with one === Launched ... === banner per launch (truncate the file when it grows; delete it only while the node is not running — the live loop keeps writing to a deleted file’s inode, unseen). The backend is sticky: with neither flag nor FRACTAL_HEADLESS set, a launch reuses the backend the node last launched with (tmux for a node that has never run headless), and the flags force and re-record it. Seats always export the parent’s backend in FRACTAL_HEADLESS (exactly true/false), so a delegated child start follows its parent unless the seat passes a flag. resume always relaunches through the recorded backend — the flags and FRACTAL_HEADLESS do not apply to it.

A fresh start requires status exactly idle. Starting refuses on: the user node; a retired node (unretire first); a paused node (resume first); a paused ancestor or tree-wide pause latch; a foreign tmux session already holding the node’s session name; a node whose recorded process group is still alive (identity-verified, so a recycled group id never blocks); and a stored config the launch re-validation rejects. Continuing from killed surfaces the recorded kill attribution as a notice. An uncapped start is allowed but warns loudly.

$ fractal node start parser
$ fractal node start parser --continue --clean --max-cost 10

attach#

Attach the current terminal to the node’s tmux session. Requires the node to be active. A headless node refuses and names its headless.log to follow instead.

$ fractal node attach parser

Signals#

Three graceful verbs and one immediate one end or suspend a running node. The graceful verbs — finish, stop, pause — require the target to be active with a run; kill also accepts paused and idle nodes — a booting node (live session, active not yet stamped) is reaped, and a never-started spawn is stamped killed. Each accepts --reason <text>, recorded with the signal. Refused signals are recorded as failed events in the activity log.

finish and stop#

$ fractal node finish [NODE] [--reason <text>] [--cancel]
$ fractal node stop [NODE] [--reason <text>]

finish ends the node gracefully after the current iteration; stop ends it after the current step. Both are queued signals the loop polls at its boundaries: a stop that lands mid-step waits for the in-flight agent to complete — however long that takes — and never signals or tears it (the immediate path is kill).

Warning

Both verbs cascade to the node’s entire subtree of active descendants (children first, so the subtree drains before the target settles). There is no single-node form: stopping a manager stops every lane under it, each after its own current step. Invoked on the user node, finish/stop broadcast tree-wide (the user node itself carries no signal).

--cancel (finish only) withdraws this node’s pending finish signal instead of sending one — it never fans out, and refuses when no finish signal is set.

$ fractal node finish parser --reason "requirements met"
$ fractal node finish parser --cancel

pause and resume#

$ fractal node pause [NODE] [--reason <text>]
$ fractal node resume [NODE]

pause suspends the subtree: the pause signal lands parent-first, each in-flight agent invocation is aborted, and the loops park with status paused, leaving their run and iteration rows open. A parked node has no tmux session — that is its normal state, not a crash. While a node (or the whole tree, via fractal pause) is paused, spawning and starting refuse anywhere beneath it; the only legal verbs on a paused node are resume, kill, and chat.

resume relaunches the parked subtree leaf-first. Each loop adopts its open run — same budgets and iteration count, the interrupted step re-entered (resuming the recorded agent session when one exists) — and run and iteration deadlines are credited for the paused span. A node still parking gets its pending pause withdrawn instead. Resuming refuses when the node is not paused or when an ancestor is still paused.

$ fractal node pause parser --reason "investigating a regression"
$ fractal node resume parser

kill#

$ fractal node kill [NODE] [--reason <text>]

Kill the node immediately: descendant sessions and recorded process groups are reaped first (re-enumerated to a fixpoint, so mid-sweep spawns are caught; a descendant refused over a launch claim in flight is retried within a bounded budget once the claim resolves), then the node’s own, and open rows are marked killed. The reap covers the loop runtime (the tmux session, or a headless or bare loop’s recorded process group) and any step group the loop recorded. Killable states are active, paused, and idle — a booting node is reaped and a never-started spawn is stamped killed so it can never activate. Two other refusals guard the reap, each before any kill state is written: a recorded process group whose identity the ps probe cannot verify names ps -p <pgid> and the record file to remove, and a record naming no process group yet — a launch claiming it — asks for a retry once its pid lands (or the record’s removal if no launch is running) instead of sweeping the claim. The attribution killed by <actor>[: reason] lands on the event, the signal, and the run row, and is surfaced as a notice on a later start --continue.

$ fractal node kill parser --reason "runaway loop"

Integration and teardown#

merge#

$ fractal node merge [NODE] [--continue] [--ignore-scope]
      [--validate <script>] [--delete] [--force|-f]

Squash-merge the node’s branch into its merge target — the configured base when set, else the dotted parent. One squash commit lands on the target; the node’s full history stays on its own branch. Merging refuses on the user node, when the node itself is active or paused, and when the target is active or paused (except from inside the target’s own loop, its normal child-merge path). Non-fatal warnings ride stderr.

Before committing, the merge holds the squash to the node’s commit scope: the staged paths outside .fractal/ are judged by the node’s scope roots and its project wiki/, with the worktree-root .gitattributes admitted only as init’s own **/_index.md merge=wiki edit (a repo-root node without a scope is unrestricted; a sub-project node without one is bounded to its project directory) — the same law fractal commit applies — and a path outside them refuses the merge. The refusal names the paths and both remedies: widen the scope with fractal node config set scope=<dirs> --path=<node worktree> and commit it with fractal commit "widen scope" --path=<node worktree> (an uncommitted config change makes the rerun skip the merge-base advance), or rerun with --ignore-scope. A fresh merge restores the target on refusal. A --continue leaves the staged squash in place, and its remedies differ: re-run with --continue --ignore-scope, or widen the scope (config set, then commit) and redo the squash (git -C <target worktree> reset --hard HEAD && git -C <target worktree> merge --squash <branch>), because the widening commit is a node commit made after the hand squash, which makes --continue refuse.

Nothing under any .fractal/ directory on the target changes except a scope root of the merging node that is, or lies under, a .fractal/ directory (a --meta node’s scope is the target’s seed directory): every other such path the squash changed returns to the target’s HEAD — a path the target tracks is restored to its content, a path it does not track is removed — with a warning naming each, so the squash never adds the node’s own seed or its descendants’ seeds. On the user node’s branch a copy the branch already tracks is stripped as well (the root owns no seed, so it is a leak); a node target keeps a copy it already tracks — its PREPARE git merge --no-ff of the child put it there — until it merges upward itself, so a child whose advance was skipped never inherits a deletion of its own live seed on its next merge of the parent. The merge-base advance then brings the target’s tree into the node’s worktree, so the node’s copy of a dropped path survives only in its branch history (git -C <node worktree> log --full-history -- <path> lists the advance that dropped the path first and the node’s own commit below it; git show <commit>:<path> on that lower commit, or git show <advance>^:<path>, reads the copy; a plain git log follows the target’s side of the advance and lists nothing). A fresh merge refuses before the squash when it would write over any file that exists untracked on the target’s disk — an ignored private file such as local.env, or the user node’s own live seed, self-ignored on the root but committable from a child — judged over every path the node added or changed since the merge-base that the target’s HEAD does not track, a file sitting where the squash would create a directory included (the prefix walk stops at a path the target’s HEAD tracks: a tracked file the node replaced with a directory is the squash’s own type change, not a collision); the refusal names the files to move aside or drop from the branch. When the target is the user node, the root’s committed tree is judged before the squash for seed directories of the root’s own dotted nodes (.fractal/<target>.*/, so a --base merge into another tree’s root judges that root) — the root owns no seed, so every one is a leak. Exactly what the strip removes — the merging node’s seed at its own project prefix and its descendants’ seeds at any depth — is named in one warning (tracks seeds of <branch> or its descendants, leaked by an earlier merge: <dirs>; this merge removes them); the rest, a same-named copy of the node’s seed under another project prefix (the node re-created at a different project path) included, get a second warning with a git -C <target worktree> rm -r -- <dirs> && git -C <target worktree> commit -m 'drop leaked node seeds' remedy line that removes them from the tree and from the root worktree’s disk (the copies are never live seeds; those sit in each node’s own worktree). A node target is not judged: its branch legitimately carries other nodes’ seeds (its ancestors’ by fork, its descendants’ by PREPARE merges, a sibling’s by the merge-base advance). The check reads the committed tree, so a --continue never reports the hand-staged seed. Whether the target is the user node is read from the repo’s record of the target branch, so a root checked out in a linked worktree is still stripped and leak-checked; a direct merge.sh call that cannot read the target’s node config warns could not read <target>'s node config; treating it as a node target instead of silently treating it as a node.

Conflicts only under .fractal/ outside the node’s scope roots resolve to the target’s content — a warning names them — and the merge continues; any other conflict fails and restores the target (the “restored” verdict is judged by the target’s state, not by git’s exit code; when it is not restored, the error names the git -C <target worktree> reset --hard HEAD to run before merging again). A squash that dies after git wrote the index (a stale or unwritable SQUASH_MSG) is reset and its squash markers cleared, and the error reads merging <branch> into <target> failed after staging; the parent worktree has been restored; resolve and retry (or its could NOT be restored form, judged the same way); failed before staging anything is reserved for a squash that staged nothing — git’s own refusal over a plain untracked file, or another git process holding the target’s index. An interrupt before the squash commit restores the target the same way and marks the merge event failed (one that lands while the event is still being opened closes it as failed too); once git commit has moved the target’s ref the squash has landed, and the merge finishes it — the merge-base advances, the event closes as completed, and the command prints Squash-merged ... and exits 0 — rather than reporting a restore. An interrupt during the advance finishes the node’s worktree update or rolls it back, warning only when an advance was underway; one during a no-op merge’s bookkeeping prints that arm’s own summary (Nothing to merge: ...) with no advance warning. After the commit, the node’s merge-base advances with a two-parent commit on the node’s branch (merge <target> (post-squash)) whose tree is the target’s post-squash tree with the node’s own seed and its descendants’ seeds kept, so the node’s worktree converges to the target and a later merge only diffs new work. The advance is skipped with a warning when the node’s worktree is dirty, on another branch, or holds an untracked or ignored file or directory in the way of a path the target now tracks — a rename on the target that lands where the node keeps a private file, or an untracked case-only alias on a case-insensitive filesystem, included, while a path the node tracks under a different case is not a collision (the update renames it), and neither is a path the node tracks at, under, or above the hit (the target turned a file into a directory or back — a type change the update performs). Move the file aside; the next merge that lands work advances it (a fresh merge offering nothing exits at “Nothing to merge” before the advance — unless the restore dropped a .fractal/ change outside the node’s own seed and its descendants’, the paths the restore warnings name, or a .fractal/ conflict outside them resolved to the target’s content, either of which still advances the merge-base so the node converges and the warning does not repeat; a node whose only offering is an edit to its own seed exits without advancing, and its next work merge advances it); a failed worktree update rolls the worktree back before skipping. A git read that fails during the advance (the node’s worktree stops answering) skips it with a warning rather than failing the landed merge, and an edit to a tracked file the commit law’s excludes hide (a force-added lock or status file) counts as dirt, so the update never overwrites it.

--continue

Finish a hand-resolved squash after a conflicted merge: redo git merge --squash in the target worktree, resolve and stage the conflicts, then merge --continue runs the merge’s own tail — .fractal/ restore and seed strip, footprint check, index refresh, commit, merge-base advance. The squash must be fully staged: unstaged tracked changes in the target refuse the continue — save any copy you need, stage (git add) the paths that belong to the resolution, and discard the rest (git -C <target worktree> checkout -- <path>); the merge restores every .fractal/ path to the target’s HEAD anyway. It must also cover the node’s current tip: a commit the node made after the hand squash (an iteration, a nested child’s merge) refuses the continue and names the redo — git -C <target worktree> reset --hard HEAD && git -C <target worktree> merge --squash <branch>. The fresh merge’s untracked-file check does not run here, so a --continue cannot prevent the overwrite of a file that exists untracked on the target’s disk: git’s own squash refuses over a plain untracked file but writes over an ignored one (git treats it as expendable) — a file the fresh merge would have refused over — and the hand squash has already done it, so move private files aside before redoing the squash by hand.

--ignore-scope

Merge out-of-scope changes instead of refusing — paths outside the node’s scope roots and its project wiki (.fractal/ paths are never judged; the worktree-root .gitattributes is admitted only as init’s own merge=wiki edit).

--validate <script>

Run a repository gate on the destination before the squash commit. Bash runs <script>, a path relative to the target worktree root, from that root after the .fractal/ restore, footprint check, and wiki index refresh — on a fresh merge and on --continue alike (repeat the flag with the continue). The script must exit 0 without changing the staged tree or leaving tracked unstaged changes; the merge stages none of its output. A failing script restores a fresh merge’s target and, with --continue, leaves the staged resolution in place for repair. A no-op merge (Nothing to merge) skips it. An absolute path, a .. component, a symlink script, or a path escaping the target worktree is refused.

--delete

Delete the node (worktree, branch, and whole subtree) after a successful merge. The teardown’s refusals and its confirmation prompt (which names the descendant count and suggests retire) run before the squash, so a refusal never lands after a merge that already committed.

--force / -f

With --delete: skip the confirmation prompt.

$ fractal node merge parser

delete#

$ fractal node delete [NODE] [--force|-f]

Recursively remove the node’s subtree, deepest first: worktrees, local and remote branches, registry rows, and radio subscriptions. History rows persist — runs, iterations, steps, events, and messages all survive in the central database. Without --force an interactive confirmation names the descendant count and suggests retire as the non-destructive alternative.

Deletion refuses on: the user node; a subtree containing any active or paused node (stop, resume, or kill it first); a caller standing inside a subtree worktree; and any locked worktree in the subtree. Warnings about unmerged work ride stderr without blocking: one per node in the subtree whose branch has commits its merge target never absorbed (each descendant judged against the deleted node’s surviving target), printed after those refusals have passed and before the confirmation prompt, and a --meta node’s edits to its target’s seed directory count as that work.

When the target’s worktree is already gone (removed out of band), delete <branch> --force takes the orphan path instead: it deregisters the branch (and any registered descendants) from the registry, refusing if any live worktree remains in the subtree.

$ fractal node delete parser --force

reconcile#

$ fractal node reconcile [NODE]

The audit step after cleaning up a node’s worktree or branch with plain git instead of delete: records one orphan event per registry row whose worktree is gone (already-recorded branches are skipped). Registry rows are kept. Prints Recorded orphaned node <branch>. per orphan, or No orphaned nodes to record.

retire and unretire#

$ fractal node retire [NODE] [--reason <text>]
$ fractal node unretire [NODE]

retire parks a node: hidden from list by default and unstartable, with its branch and history kept. It refuses active/paused nodes, already-retired nodes (Cannot retire: node is already retired., so the recorded pre-retire status stays the real one), and the user node. The pre-retire status is recorded on the retire event, and unretire restores it (falling back to idle). An idle restore returns the node to the unsettled pool, so the width and descendant caps are re-checked as at spawn — refused over cap, with no override.

$ fractal node retire parser --reason "superseded"
$ fractal node unretire parser

Inspection#

status#

$ fractal node status [NODE]

Print the node’s current status, possibly decorated: active (pausing), active (stopping), or active (finishing) when a signal is pending, and exited (<reason>) when the latest run recorded why it ended. The read is self-reconciling — a crashed node, active on record with its loop runtime (the tmux session, or a headless or bare loop’s recorded process group) provably gone, is healed to exited first.

$ fractal node status parser
active (finishing)

diff#

$ fractal node diff [NODE]

Show a node’s drift from its recorded template. The recorded folder is re-rendered at its recorded commit with exactly its recorded input values, the effective set (the recorded include/exclude listing) is applied, and each file is compared against the node’s live copy: NODE.md, steps/, scripts/, skills/, and — for each agents/<agent>/ file — the live .<agent>/ copy, all in the node’s data directory. A live symlink and a file the template does not carry are never judged; a template file the node lacks is drift. Literal braces are valid output and are compared like any other text. Source defaults are not merged again; deleting a required recorded input causes a rendering refusal. The recorded commit resolves as long as it is reachable, so a template folder moved or deleted later still diffs at the commit that seeded the node.

Drift prints one unified diff per file (template/<file> against node/<file>); a recorded listing entry the template no longer carries warns on stderr. The exit code is the contract — 0 clean, 1 drift (the command’s own nonzero outcome), 2 a core refusal, a node with no recorded template included — so scripts branch on it rather than parse the output.

$ fractal node diff parser

list#

$ fractal node list [NODE] [--all] [--retired] [--max-depth <n>] [--json]
      [--status <s[,s...]>] [--live] [--count] [--csv]

List a node’s descendants — the listing never includes the target’s own row (use status for that). Columns, in order: status, detail, node, title, max_cost, spend, max_depth, max_children, max_descendants, last, end_reason.

status is always the bare lifecycle word; every qualifier rides detail, which composes (;-joined) any of: a pending signal (pausing/stopping/finishing), an exited run’s recorded end reason, run exhausted: <reason> on a completed run that ended on its iteration cap and final iteration failed on a drained finish whose last iteration died (a clean drained finish stays bare), orphaned for a registered node whose worktree is gone, model drop for an unresolved served-model divergence, iteration gap <span> for numbers that advanced with no recorded row, and PAUSED: billing while the newest launches carry the dead-credits signature. end_reason is detail’s typed counterpart, a closed vocabulary naming a settled row’s landing: goal_met, run_exhausted, or final_iteration_failed on a completed row; cost_budget, timeout, setup_abort, final_iteration_failed, or other (recorded but unmapped) on an exited row; null when no reason is recorded (e.g. a reconcile-healed crash) and on every other status. spend is the current run’s subtree cost at the scope max_cost is enforced at, blank for a node that has never run. Blank limit columns mean unlimited. last is the age of each node’s newest activity, with a ! suffix flagging an active node quiet past max(step_timeout, 5m). On a TTY, statuses render bracketed ([active]); machine output stays unbracketed.

--all

Include retired nodes.

--retired

Show only retired nodes.

--max-depth <n>

Maximum child depth to include (1 = direct children only).

--status <s[,s...]>

Filter to one or more comma-separated statuses. Valid values: active, paused, idle, completed, stopped, exited, killed, failed, retired, plus the listing-only pseudo-status orphan (a registered node whose worktree is gone). Unknown values refuse with the valid list.

--live

Trust each child’s real state: relabel a crashed active node — its loop runtime (the tmux session, or a headless or bare loop’s recorded process group) provably gone — as exited, a booting idle node as active, and drop nodes whose worktree is gone. This view is read-only; the plain listing instead persists the crash heal and flags worktree-less rows as orphan (or <status> (orphaned) when settled).

--count

Print only the number of matching nodes (mutually exclusive with the row formats: a bare number is a shape neither a CSV nor a JSON consumer asked for).

--csv

Force CSV output (already the default when piped).

--json

Emit a JSON array of row objects with typed fields (mutually exclusive with --csv); scripted consumers should prefer it over CSV scraping.

$ fractal node list --status active,paused
$ fractal node list parser --max-depth 1 --count
$ fractal node list --json

activity#

$ fractal node activity [NODE] [--limit <n>] [--csv] [--json]

Show the node’s lifecycle activity, most recent first. Columns (bind by header name, not position): timestamp, node, event_id, step_id, iter_id, run_id, event, actor, step_name, step, iter, status, exit_code, metadata, duration, cost. The cost column is the row’s own-node step cost only — subtree spend, children included, is fractal node cost spent. --limit <n> bounds the rows returned; --json emits a JSON array of row objects (mutually exclusive with --csv).

$ fractal node activity parser --limit 10 --json

Approvals#

Steps whose frontmatter sets requires_approval: true run, then gate on the direct parent before the loop advances: once the step’s agent invocation succeeds, the child waits for approval, polling (with sync passes paced by its wait interval) until the approval lands.

approve#

$ fractal node approve NODE [STEP_ID]

Approve a child’s gated step — must be run from the direct parent. NODE (required) is the child’s branch; STEP_ID defaults to the child’s active gated step. Approval refuses when the caller is not the direct parent, when the child has no active step, or when the step does not require approval.

pending#

$ fractal node pending [NODE] [--csv]

List direct children’s steps awaiting this node’s approval. Columns: branch, step_id, step, step_name. Only gated steps on active or paused children appear.

$ fractal node pending
$ fractal node approve parser 1
Step 1 on main.parser approved.

Chat#

chat#

$ fractal node chat [NODE] PROMPT [--session <id>] [--current] [--resume]
      [--model <name>]

Send one prompt to a node’s agent and stream the reply. The prompt is required as the second argument. A bare chat opens a fresh session seeded with the node’s NODE.md and chat mode, so the agent answers in the node’s context. Chatting has no side effects on the node’s cost or session records, and it is one of the few verbs legal on a paused node. The resulting session id prints on stderr as session: <id> so the thread can be continued.

--session <id>

Fork the named session (default: a fresh session).

--current

Fork the node’s live loop session — ask about what the loop is doing right now. Mutually exclusive with --session/--resume; refuses when no live session exists.

--resume

Continue --session in place instead of forking it. Resuming the live loop session refuses (it would perturb the loop). Codex agents can resume sessions but cannot fork them.

--model <name>

Model override (default: the node’s configured model).

$ fractal node chat parser "Summarize your progress so far."
$ fractal node chat parser "Why did the last test fail?" --current

Retuning#

reseed#

$ fractal node reseed [NODE] [--ref <ref> | --template <path[@ref]>]
      [--force|-f]

Rewrite a node’s seed surfaces from its recorded template. The recorded folder is re-rendered at its recorded commit with exactly its recorded input values, and steps/, scripts/, skills/, and the per-agent files are rewritten from the result: files the node lacks are added and files it has are overwritten, so the node matches its template’s effective set (an excluded file stays gone); nothing is ever deleted, and a live symlink (the agent auth links) stands. NODE.md, config.json, and memory/ are never touched, so an operator’s charter edits survive. The reseed lands as an event on the node’s record, and _template.toml advances to the commit actually read; the listing is preserved. A bare reseed keeps the recorded values exactly. An explicit --ref or --template refresh adds the target template’s defaults only for missing keys, keeping every recorded value, including values first obtained from a default. It records the complete merged map; a new required input without a default refuses before live writes.

Changing recorded values does not update generated files until reseeding. Reseed preserves the charter even when those values or the template change, so a successful reseed can leave charter drift. Edit NODE.md directly when the charter needs a change.

Reseed rewrites the live steering surface, so it refuses over an active or paused node without --force; it always refuses from the node’s own worktree — a node may not edit its own seed.

--ref <ref>

Committish to read the recorded template folder at (default: the recorded commit). Mutually exclusive with --template. A ref at which the recorded path is no longer tracked (the folder moved or was retired) refuses, naming the path and the ref — re-point with --template.

--template <path[@ref]>

Re-point the node at another template folder: the new path and the commit read (the node branch’s own tip unless @<ref> names one) land in _template.toml, and the node reseeds from it — memory and config stay intact, so a moved template is followed with one command. One notice names the @<root> form when the root branch holds a different copy of the folder (a plain or --ref reseed stays silent).

--force / -f

Reseed even while the node is active or paused.

$ fractal node reseed parser
$ fractal node reseed parser --template templates/crew@main

update#

$ fractal node update NODE [--title <text>] [--max-cost <usd>]
      [--max-iter-cost <usd>] [--max-step-cost <usd>]
      [--reserve-budget <usd|N%>] [--step-timeout <dur>] [--max-depth <n>]
      [--max-children <n>] [--max-descendants <n>]

Update a child node’s configuration mid-flight — the supported retune path. It writes the registry row and the child’s config.json together, and a running loop picks the new caps up at its next iteration boundary. (A direct edit of the child’s config.json is honored at the same boundary and back-filled to the registry with a warning.) At least one option is required; each change is echoed key: old -> new (unset for a prior null). The merged result is validated exactly as at init.

The options mirror their node init counterparts: --title, --max-cost, --max-iter-cost, --max-step-cost, --reserve-budget (USD or N% of the effective max-cost), --step-timeout, --max-depth, --max-children, and --max-descendants. When --max-cost changes and the reserve was left at its default, the 10% reserve is re-derived against the new cap. The user node cannot be updated (set its config with node config set from the root worktree instead), and the target’s parent worktree must exist.

$ fractal node update parser --max-cost 100 --max-children 2
max_cost: 10.0 -> 100.0
max_children: unset -> 2
reserve_budget: 1.0 -> 10.0

Time budgets: fractal node time#

time remaining#

$ fractal node time remaining [NODE] [--scope run|iter|step]

Print the seconds left before the next timeout fires, e.g. 600s. By default the soonest of the configured run, iteration, and step deadlines answers; --scope narrows to one: run reads timeout, iter reads iter_timeout (falling back to interval), step reads step_timeout. Paused spans are credited back to run and iteration deadlines. Two non-numeric answers are distinct: no limit means no timeout is configured for the queried scope; not running means a limit exists but no run, iteration, or step is currently active.

$ fractal node time remaining parser --scope iter
600s

Cost budgets: fractal node cost#

Budgets and spend are per-run: each launch arms max_cost anew, and all three commands default to the current run (scope to a prior run with --run <id>). Amounts print as $X.XXXX. A deleted node still resolves through the tree’s shared history: spent and breakdown answer from its latest recorded run, while remaining always prints no budget (caps die with the node).

cost remaining#

$ fractal node cost remaining [NODE]
      [--run <id> | --iter <id> | --step <id>]

Print the remaining budget: max_cost minus the run’s subtree spend — the node’s own steps plus every descendant run chained beneath it. --iter and --step scope to a specific iteration’s max_iter_cost or step’s max_step_cost headroom instead; at most one of --run/--iter/ --step may be given. Prints no budget when the relevant cap is unset, and never goes negative (clamped at $0.0000).

cost spent#

$ fractal node cost spent [NODE] [--run <id> | --iter <id> | --step <id>]
      [--max-depth <n>]

Print the total recorded cost, children included; --max-depth 0 restricts to this node only. --run, --iter, and --step scope the reading to one run, iteration, or step. Prints untracked when the scope ran only cost-untracked steps; when ended steps carry no recorded cost, a stderr disclosure notes how many unpriced steps were excluded.

cost breakdown#

$ fractal node cost breakdown [NODE] [--run <id>] [--max-depth <n>] [--csv]

Print a per-node table — columns node, max_cost, spent — for the current run (or --run <id>). The target’s own row leads, followed by each in-subtree descendant; descendants whose registry rows are gone but whose spend is still recorded append as <branch> (deleted) rows, so the rows always sum to cost spent.

$ fractal node cost remaining parser
$6.7890
$ fractal node cost spent parser --max-depth 0
$3.2100
$ fractal node cost breakdown parser

Configuration: fractal node config#

A node’s run parameters live in its config.json; the full key reference is in Configuration.

config get#

$ fractal node config get KEY

Print one config value. Unknown keys refuse with the valid-keys list. Booleans render as true/false, list values (scope and clone_dirs) one item per line, and an unset key prints nothing (exit 0).

config set#

$ fractal node config set KEY=VALUE [KEY=VALUE ...]

Write config values, echoing key: old -> new per key. The key=value form is required; an empty value refuses (use key=null to clear a key). Types are enforced at the boundary: boolean keys accept true/false/ null; integer keys a non-negative integer (max_iters must be greater than 0); cost keys a non-negative number — the ceilings (max_cost, max_iter_cost, max_step_cost) must additionally be positive, with 0 refused by the merged validation, while reserve_budget may be 0; the list keys scope and clone_dirs a comma- or space-joined list of repo-relative roots (stored in canonical form; . is a legal scope root but never a clone_dirs entry); every other key stores a literal string. The merged result is validated — cost positivity and ordering, reserve range, duration suffixes, pacing exclusivity — before anything is written; init’s additional flag checks (the agent registry, provider support, the base worktree) do not run here. Multi-key sets are checked upfront, so no earlier key lands when a later one is rejected.

A change to the init-fixed keys root, user, and project always refuses: <key> is fixed at init and cannot be set. (Re-setting the currently stored value is accepted as a no-op.)

$ fractal node config get max_cost
10.0
$ fractal node config set max_iter_cost=1 sleep=30s
max_iter_cost: unset -> 1
sleep: unset -> 30s