npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@maleus/yamloop

v0.1.2

Published

Run agent loops (Claude Code, OpenCode, ...) as a YAML-described graph of prompt and command nodes.

Readme

yamloop

Run agent loops (Claude Code, OpenCode, …) as a YAML-described graph of prompt and command nodes, with conditional branching, session reuse, rate-limit handling, and an optional supervisor agent.

Most non-trivial agent work is a loop: code → review → adversarial review → test → fix-tests → test → commit. This tool externalises that loop into a YAML file and runs it autonomously — launching the right agent CLI, keeping or clearing the agent session between nodes, branching on exit codes or regex matches, and re-injecting captured stdout/stderr into the next prompt.

Quick start

yamloop -c my-flow.loop.yaml -i "develop a CRUD for users"

Config files follow the <name>.loop.yaml convention (e.g. dev.loop.yaml, milestones.loop.yaml). Runnable examples ship in examples/:

  • examples/echo-trace.loop.yaml — a three-step command-only loop.
  • examples/templated.loop.yaml — DRY templates + instances expansion.
  • examples/dev.loop.yaml — the full dev → review → adversarial → test → fix shape.
  • examples/plan-dev.loop.yaml — implement ONE docs/plans/… file end-to-end (dev → review → type:check → lint → test → scoped agent-commit).
  • examples/plans-chain.loop.yaml — the same cycle chained over N plans in order, one stacked commit each; pre-wired for 3 plans, add/remove a block to scale.

Authoring loops with Claude Code

The tool ships a Claude Code skill at SKILL.md — copy it to .claude/skills/yamloop/SKILL.md in your own repository so Claude Code discovers it. When you ask Claude Code to "write a loop", "automate this dev/review/test cycle", or invoke /yamloop, the agent loads that skill and drafts a runnable *.loop.yaml for you — it knows the strict schema, the edge resolution rules, the session lifecycle, the template/instance macros, the supervisor protocol, and the rate-limit patterns covered below. Use it instead of hand-writing the YAML from scratch.

The skill only authors configs; it does NOT run the loop. Once the YAML is on disk, drive it with the CLI.

CLI options

| Flag | Default | Effect | | --------------------- | ------------------------ | ----------------------------------------------------------------------------------------- | | -c, --config <path> | required | Path to the *.loop.yaml config. | | -i, --input <text> | required (not w/ resume) | Value bound to {{input}} in templates. | | --run-id <id> | timestamp + suffix | Custom identifier for the run folder. | | --runs-dir <dir> | .yamloop/runs | Where to create run folders. | | --resume <run-id> | off | Resume a previously interrupted run (see Resuming a run). | | --dry-run | off | Validate the config and print the graph, then exit. | | --no-supervisor | off | Disable the supervisor even if configured. | | --no-tui / --tui | on if TTY | Opt out of / force the live dashboard. | | --tail <N> | 4 | Stdout lines tailed in the dashboard (max 40). | | --tail-stderr | off | Include stderr chunks in the dashboard tail. | | --trace | off (auto under TUI) | Persist a Langfuse-like JSON trace under runs/<id>/trace.{ndjson,json}. | | -h, --help | — | Print usage. |

Each run writes to .yamloop/runs/<run-id>/:

  • state.json — full context snapshot (sessions, per-node visit_counter + append-only visits[] with stdout/stderr/exit_code, paused flag).
  • summary.json — path traversed, start/end timestamps, final status, supervisor checks aggregate.
  • <index>-<node-id>.log / .stdout / .stderr — one set per executed node.
  • supervisor/<index>-<timestamp>-<trigger>.json — one per supervisor check (when configured).
  • rate-limits/<index>-<timestamp>-<node>.json — one per rate-limit retry.

Exit statuses (summary.json.exit_status): ok, max_visits, error, aborted, supervisor_abort, supervisor_max_calls, supervisor_timeout, rate_limit_exhausted, completed_with_failures.

YAML at a glance

name: dev-loop
entry: dev

defaults:
  agent: claude
  max_visits: 5

agents:
  claude:
    bin: claude
    new_session_args: ["-p", "{{prompt}}"]
    resume_session_args: ["--resume", "{{session_id}}", "-p", "{{prompt}}"]
    session_id_regex: "session_id=([a-f0-9-]+)"

sessions:
  main-dev:

nodes:
  dev:
    type: prompt
    session: main-dev
    prompt: "{{input}}"
    next: review

  review:
    type: prompt
    session: main-dev
    prompt: "Review the code you just wrote."
    next: test

  test:
    type: command
    cmd: pnpm test
    on:
      exit_0: commit
      exit_non_zero: fix-tests

  fix-tests:
    type: prompt
    session: main-dev
    prompt: |
      Fix these tests:
      stdout:
      {{nodes.test.stdout}}
      stderr:
      {{nodes.test.stderr}}
    next: test

  commit:
    type: command
    cmd: git add . && git commit -m "loop: auto"
    terminal: true

Template variables

Prompts and command strings are rendered with a Mustache-like engine. Unknown variables throw — there is no silent fallback.

| Variable | What it is | | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | {{input}} | CLI -i value | | {{run_id}} | Current run id | | {{nodes.<id>.stdout}} / .stderr / .exit_code | Last completed visit of node <id> | | {{nodes.<id>.stdout_path}} / .stderr_path / .log_path | Absolute paths to the per-visit log files — use these instead of inlining content when payloads are large (avoids E2BIG on spawn) | | {{env.<NAME>}} | A process env var |

Edge resolution (on:)

Evaluated in order, first match wins:

  1. terminal: true → stop.
  2. on.exit_0 if exit_code === 0.
  3. on.exit_non_zero if exit_code !== 0 && exit_code !== null.
  4. on.stdout_matches (regex against captured stdout).
  5. on.stderr_matches (regex against captured stderr).
  6. on.default.
  7. Fallback next:.
  8. null → stop.

A non-zero exit routed via the fallback next: (no matching on: clause) is flagged as a silent failure and turns the run into completed_with_failures.

Sessions

session: <name> is a logical name local to the loop, declared up front in the root sessions: section (referencing an undeclared session is a config error). The first prompt node referencing it creates a fresh agent session and stores its id (extracted via agents.<n>.session_id_regex). Subsequent nodes with the same name resume that session. Set new_session: true to force a fresh session and drop the stored id.

Templates & instances (DRY)

When the same node structure repeats (N milestones, N files to migrate, …), declare it once as a templates: entry and instantiate it N times. The loader expands templates into concrete nodes BEFORE schema validation — the runner sees a flat graph.

templates:
  cycle:
    params: [n, label, on_success]
    nodes:
      work-{{n}}:
        type: prompt
        session: m{{n}}
        prompt: "Work on milestone {{n}} ({{label}})."
        on: { exit_0: verify-{{n}}, exit_non_zero: halt }
      verify-{{n}}:
        type: command
        cmd: 'echo "verified {{label}}"'
        on: { exit_0: "{{on_success}}", exit_non_zero: halt }

instances:
  - template: cycle
    vars: { n: 1, label: foundation, on_success: work-2 }
  - template: cycle
    vars: { n: 2, label: features, on_success: done }

nodes:
  done: { type: command, cmd: "echo done", terminal: true }
  halt: { type: command, cmd: "exit 1", terminal: true }

Substitution is string-only, both keys and string values are substituted, params: must be exhaustive, unknown placeholders / ID collisions / instances without a templates: section throw at load time. No nested templates, no per-instance overrides.

Anti-loop guardrails

Each node has a max_visits cap (per-node, then defaults.max_visits, then 5). Exceeding it aborts with exit_status: "max_visits" and a non-zero CLI exit code. Use this to fence fix → test → fix → … loops.

For earlier intervention and for catching failures the graph doesn't anticipate, see the Supervisor section below.

Live dashboard

When run from an interactive terminal, yamloop displays a compact dashboard pinned at the bottom of the terminal and auto-starts the web trace viewer so you can click into the run while it's running.

yamloop: implement-milestone-4
run-id : 2026-06-13T13-42-uxXX
entry  : code-4
runDir : .yamloop/runs/2026-06-13T13-42-uxXX
view   : http://localhost:4310/?run=2026-06-13T13-42-uxXX

▶ code-4 (prompt, visit 1/1)
✓ code-4 exit=0 in 4m 12s
▶ review-4 (prompt, visit 1/1)
✓ review-4 exit=0 in 2m 03s
▶ test-4 (command, visit 1/8)
─────────────────────────────────────────────────────────────────────────────
 loop: implement-milestone-4  ·  run: 2026-06-13T13-42-uxXX  ·  6m 20s
 ▶ test-4  visit 1/8  · command  (5s)
 │  RUN  v3.2.4 /Users/adrien/.../studio-backend
 │  ✓ src/features/users (38 ms)
 │  ✓ src/features/auth (12 ms)
 │  ✗ src/features/orders > computeTotal
 ────── view → http://localhost:4310/?run=2026-06-13T13-42-uxXX ────────

A single Ctrl-C during the run aborts and exits (130). After natural completion, a second Ctrl-C closes the viewer.

If you pipe yamloop (into tee, in CI, etc.), TUI is OFF by default and the legacy verbose stream is restored. Force it back on with --tui.

Live output in the web viewer. Click a node in the graph/waterfall to open its detail panel — the Live output section streams that node's stdout/stderr as it scrolls by, exactly like the TUI tail (assistant text + 🔧 tool_use lines for claude nodes, raw output for command nodes). It tails while the node runs and freezes on the final output when it ends; on a finished run it shows the last visit's captured output. Mechanically, the runner tees each node's output to <runDir>/live/<node>.log (truncated per visit) and the viewer tails it over SSE (/api/runs/:id/live/:node).

Presets

Presets are built-in, parameterized loops for recurring flows — so a common task doesn't need a hand-written *.loop.yaml.

yamloop preset list                 # presets + their params
yamloop preset show <name> [flags]  # print the rendered YAML

# Run directly, no file:
yamloop --preset plan --plan /abs/path/to/docs/plans/my-plan.md \
  --typecheck "pnpm --filter foo type:check" \
  --lint      "pnpm --filter foo lint" \
  --test      "pnpm --filter foo test"

# Or materialize to disk and edit it (e.g. with Claude Code), then run with -c:
yamloop preset init plan -o my-plan.loop.yaml --plan /abs/path/to/plan.md
yamloop -c my-plan.loop.yaml -i go

The shipped plan preset implements a docs/plans feature end-to-end: dev (reads the plan, implements) → review (/flow-review-simulate + fixes) → type:checklinttest (each gate with its own fresh-session fix-loop) → commit (an agent stages only the plan's files, then gt create, routed on a COMMIT_OK sentinel). Only --plan is required; the gate commands default to pnpm type:check / pnpm lint / pnpm test and are overridable per flag. Presets live in src/presets/registry.ts.

Resuming an interrupted run

Anything that kills the process mid-run — Ctrl-C, crash, kill — leaves runs/<run-id>/state.json on disk with the full context (sessions, per-node visit_counter, append-only visits[], paused flag). Pass --resume <run-id> to pick up exactly where the run stopped:

yamloop -c my-flow.loop.yaml --resume 2026-06-13T13-42-uxXX

What happens on resume:

  • state.json is rehydrated in place — the same run folder keeps writing — and the run keeps its original run-id, started_at, and input. Logs, supervisor checks, rate-limit retries from before the interruption all stay where they were.
  • Sessions are reused as-is. Any prompt node bound to a session whose id is already stored falls back to its agent's resume_session_args (e.g. claude --resume <session_id> -p <prompt>) — no manual re-injection needed.
  • The last visit that was running when the interruption hit (ended_at: null) is dropped (visit popped, visit_counter decremented) and the node is re-executed. Same semantics as a rate-limit transient retry — the interrupted attempt doesn't count against max_visits.
  • If the last completed visit had no in-progress successor, the runner re-runs resolveNext against that visit's outcome and starts there. If the last completed node was terminal (or routed to null), the run is considered already done and exits with ok.
  • Any paused: true flag in state (rate-limit sleep, supervisor wait) is cleared on resume — the wait was transient. If the underlying condition still holds, the detector will fire again on the next attempt.
  • The final summary.json reflects the full lifecycle: path[] is seeded from prior finalized visits and grows with the resumed portion.

Constraints — these are errors at startup, not silent surprises:

  • -i, --input is forbidden with --resume (input is restored from state.json to keep already-rendered {{input}} references coherent).
  • --run-id is forbidden with --resume (the run id is the one you're resuming).
  • -c, --config is still required and the config's name must equal state.loop_name. Mismatch errors with a clear message — pass the same YAML you started the original run with.

Known sharp edges:

  • If a prompt was sent to Claude and partially processed before the interrupt, the resumed run re-sends the same prompt on the same session. Claude sees it twice; in -p mode this usually means a fresh answer, but the prior partial response is preserved in the session history.
  • A rate-limit sleep deadline in flight when you Ctrl-C is not restored — on resume the executor runs immediately and, if the limit still holds, re-detects it and waits again.
  • The supervisor's prior checks stay on disk (supervisor/*.json) for audit, but the scheduler starts fresh on the resumed segment — past decisions are not replayed.

Rate-limit handling

When a prompt node hits an agent rate limit (Claude usage quota, 429, overloaded_error, …), the runner detects the failure, sleeps until the limit resets, then retries the same prompt with the same session. The retry does not consume visit_counter.

Opting in is one line of YAML. Declare rate_limit: {} on the agent and the runner uses the shipped default patterns (claude_usage_limit, claude_429, anthropic_overloaded). Override by declaring your own patterns: block — your patterns replace the defaults entirely.

agents:
  claude:
    bin: claude
    new_session_args: ["-p", "{{prompt}}"]
    resume_session_args: ["--resume", "{{session_id}}", "-p", "{{prompt}}"]
    rate_limit: {} # opts in with built-in patterns

Two detection paths cooperate:

  • Programmatic — agent-level rate_limit.patterns. Stdout/stderr are matched against the patterns in order; the first match wins. The pattern extracts the reset time from the matched groups (clock_hhmm, clock_h12_ampm, duration_minutes, iso8601), or falls back to default_wait_minutes (default 30).
  • Supervisor — when no pattern matches but the failure routes to a halt/dead-end, the supervisor can return { action: "wait", until|duration_ms, retry_node } to schedule the same wait-and-retry behaviour from outside the YAML.

Each rate-limit retry is logged under runs/<id>/rate-limits/ and emits a rate_limit_wait hook event. After max_consecutive_retries (default 5) consecutive rate limits on a single node, the run aborts with exit_status: "rate_limit_exhausted".

agents:
  claude:
    bin: claude
    new_session_args: ["-p", "{{prompt}}"]
    resume_session_args: ["--resume", "{{session_id}}", "-p", "{{prompt}}"]
    session_id_regex: "session_id=([a-f0-9-]+)"
    rate_limit:
      patterns:
        - name: claude_usage_limit
          regex: "Claude usage limit reached.*reset at (\\d{1,2}):(\\d{2})"
          reset_format: clock_hhmm
          reset_groups: [1, 2]
        - name: claude_429
          regex: "429 Too Many Requests"
        - name: anthropic_overloaded
          regex: "anthropic.*overloaded_error|503 Service Unavailable"
      default_wait_minutes: 30
      max_consecutive_retries: 5

# Optional global fallback for agents without their own rate_limit:
rate_limit:
  default_wait_minutes: 30
  max_consecutive_retries: 5

Programmatic detection runs only on prompt nodes. Rate limits surfacing through a command node (npm registry 429, …) are caught by the supervisor through on_unhandled_failure and routed via wait.

Supervisor

The supervisor is an optional meta-agent that watches the run and intervenes when the deterministic graph can't recover. It is an exception mechanism, not the default flow. Design the graph to handle every failure path you can anticipate with explicit on: edges; the supervisor catches what you didn't anticipate.

Minimal opt-in: one line. supervisor: { agent: <name> } is enough — mission falls back to a shipped generic mission (abort over speculative patching, treat halt_nodes as hard signals, no claimed progress without evidence), and on_unhandled_failure is auto-enabled when no trigger is set. Override either when the run needs project-specific guardrails:

# Minimal opt-in — default mission, on_unhandled_failure auto-enabled
supervisor:
  agent: claude

# Project-specific override
supervisor:
  agent: claude
  mission:
    objective: "Implement the 4 milestones as stacked PRs, all tests + lint + typecheck green before each commit."
    constraints:
      - "Never bypass the husky pre-commit hook (no --no-verify)."
      - "Never skip tests of a milestone to advance to the next."
      - "If a fix-loop diverges after 3 attempts without measurable progress, abort instead of patching blindly."
  on_unhandled_failure: true # fire when a failure is not handled by the graph
  on_suspect_visits: true # fire when a node hits suspect_visits BEFORE max_visits
  suspect_visits: 3
  halt_nodes: ["halt"]
  max_calls: 10
  await_supervisor_timeout_ms: 300000

Triggers — handled vs unhandled failures

The crucial distinction on_unhandled_failure respects:

  • Handled failureexit_code != 0 AND the graph produces a normal recovery node (e.g. test exit 1 → fix via on.exit_non_zero: fix). Supervisor stays asleep.
  • Unhandled failure — fires unhandled_failure(reason) on any of:
    1. routed_to_haltresolveNext returns a node listed in supervisor.halt_nodes.
    2. dead_endresolveNext returns null on a non-terminal node.
    3. max_visits — a node exhausts its budget.

Decision protocol

The supervisor's stdout must end with a JSON block (last non-empty line, OR inside <decision>…</decision>, OR inside the last ```json fence):

{ "action": "continue" | "resume" | "pause" | "abort" | "patch" | "jump" | "wait",
  "reason": "...",
  "inject_prompt": { "session": "...", "prompt": "..." },
  "next_node": "<existing-node-id>",
  "reset_visits": ["<node-id>", "..."],
  "until": "<ISO8601>",
  "duration_ms": 0,
  "retry_node": "<existing-node-id>" }
  • continue — no-op.
  • resume / pause — exit / enter a paused state.
  • abort — stop with supervisor_abort.
  • patch — apply any combination of inject_prompt (fire-and-forget into a session), reset_visits (counter reset; history preserved), next_node (override routing).
  • jumppatch with just next_node.
  • wait — sleep until until / duration_ms, then force-route to retry_node (rate-limit recovery surfaced through commands).

Unknown action, missing fields, JSON parse error, or unresolved node ids → recorded in supervisor/<i>.json with parse_result.ok = false. For critical triggers (on_unhandled_failure, on_suspect_visits), a synthetic abort is pushed so the runner never hangs.

Snapshot

The supervisor sees: run metadata, ordered path with timestamps, per-node visit history (bounded by max_visits_in_snapshot, each visit's stdout/stderr bounded by max_snapshot_log_chars), graph topology, sessions map, failed/suspect node detail, and its own prior decisions in this run. It is a real agent and can also inspect the workspace via its tools (git status, git diff, pnpm lint, …).

Hooks

hooks: is a top-level list of observers fired around every node and around the run as a whole.

| Type | What it does | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | console | Pretty-prints each event to stderr (or one JSON line per event with format: json). | | webhook | POSTs the event payload as JSON to a URL. Supports method, headers, timeout_ms. | | command | Runs a shell command per event with LOOP_EVENT, LOOP_RUN_ID, LOOP_NAME, LOOP_NODE_ID, LOOP_NODE_TYPE, LOOP_EXIT_CODE, LOOP_NEXT, LOOP_EXIT_STATUS, and LOOP_PAYLOAD (JSON) as env vars. | | otel | Opens OTEL spans per node and per run via @opentelemetry/api (lazy import). Plug your tracer provider in the host process. | | trace_file | Writes a Langfuse-like trace under runs/<id>/trace.{ndjson,json} for the web viewer. |

Events: run_start, run_end, node_start, node_end, node_error, supervisor_check_start, supervisor_check_end, supervisor_inject_start, supervisor_inject_end, rate_limit_wait. If events: is omitted, the hook subscribes to all of them.

hooks:
  - type: console
    events: [run_start, node_end, run_end]
  - type: webhook
    url: https://hooks.slack.com/services/T000/B000/XXX
    events: [node_error, run_end]
  - type: command
    cmd: 'echo "$LOOP_EVENT $LOOP_NODE_ID exit=$LOOP_EXIT_CODE" >> audit.log'
    events: [node_end]
  - type: otel
    tracer_name: yamloop

Hooks run in parallel per event; a throwing hook is caught and logged — it never interrupts the run. node_start / node_end receive the run's AbortSignal so slow hooks are cancelled on SIGINT; run_start, node_error, run_end are never cancelled so terminal notifications always reach their destination.

For richer integrations, pass hooks: Hook[] programmatically to runLoop(...) — the Hook interface in src/hooks/types.ts only requires name, events, and async emit(payload, signal?).

Adding a new agent CLI

The GenericAgentAdapter is parameterised entirely from YAML. To support a new agent CLI, declare it under agents: with:

  • bin — executable name (on PATH or absolute path).
  • new_session_args / resume_session_args — argv templates supporting {{prompt}} and {{session_id}}.
  • session_id_regex — capture group 1 (or full match) on stdout/stderr.

claude preset. An agent whose bin is claude is streamed and metered by default: at load time (config/agent-presets.ts) yamloop sets output_parser: claude_stream_json, usage_format: claude_json, rate_limit: {} and injects --output-format stream-json --verbose into both argv templates. That's what drives the live dashboard tail and the TOKENS / COST counters. Every field is a default — an explicit value in the YAML always wins, and setting output_parser: text opts out of the flag injection too. So a bare claude agent (bin: claude + new_session_args: ["-p", "{{prompt}}"]) already streams.

If a future CLI needs custom handling (multi-line prompt via stdin, JSON output parsing, …), implement a dedicated AgentAdapter in src/agents/ and wire it in runner.ts > buildAdapters.

Programmatic API

runLoop(options) is exported from src/runner/runner.ts and can be called directly without going through the CLI. Useful for embedding the orchestrator in another tool, or for testing:

import { runLoop } from "@maleus/yamloop";

const summary = await runLoop({
  config, // a parsed LoopConfig
  input: "build the thing",
  runDir: "/tmp/my-run",
  runId: "run-1",
  adapters: { claude: myAdapter }, // inject your own AgentAdapter
  hooks: [myCollectorHook], // observe events without YAML
  silent: true, // suppress runner stdout/stderr
});

See tests/ for end-to-end usage examples — every test calls runLoop directly with a mocked AgentAdapter.

Project layout

src/
├── index.ts                 # CLI entry, arg parsing, run-id, output formatting
├── config/
│   ├── schema.ts            # Zod schemas (LoopConfig, nodes, agents, on)
│   ├── loader.ts            # YAML parse + schema validate + graph validate
│   └── expand.ts            # Template + instance expansion
├── template/
│   └── render.ts            # {{path.to.value}} renderer with strict lookup
├── agents/
│   ├── adapter.ts           # AgentAdapter interface
│   └── generic.ts           # GenericAgentAdapter (spawn + session_id regex)
├── state/
│   └── context-store.ts     # Sessions + per-node outputs, persisted as state.json
├── executors/
│   ├── prompt.ts            # Prompt node: render → adapter.run → record session
│   ├── command.ts           # Command node: spawn shell, stream, capture
│   └── types.ts             # NodeOutcome
├── hooks/
│   ├── types.ts             # Hook interface + HookPayload union
│   ├── registry.ts          # HookRegistry.fromConfig + dispatch
│   ├── console.ts / webhook.ts / command.ts / otel.ts / trace-file.ts
├── rate-limit/
│   ├── detector.ts          # Pattern → RateLimitInfo
│   └── sleep.ts             # Cancellable sleep helper
├── supervisor/
│   ├── types.ts             # SupervisorDecision union, CheckRecord, PendingDecision
│   ├── snapshot.ts          # buildSnapshot — what the supervisor agent sees
│   ├── decision.ts          # parseDecision (Zod validation + graph reference check)
│   ├── control-queue.ts     # FIFO queue for scheduler → runner signaling
│   ├── events.ts            # RunnerEventBus (typed EventEmitter)
│   └── scheduler.ts         # SupervisorScheduler: triggers, mutex, max_calls, persist
├── tui/                     # Live dashboard (renderer + key handling)
├── viewer/                  # Preact-based web trace viewer
└── runner/
    ├── runner.ts            # State machine: loop until current=null or aborted
    └── resolve-next.ts      # Edge resolution (on: → next:)

Development

pnpm install
pnpm build           # tsc + bundle the web viewer
pnpm test            # vitest run
pnpm type:check      # tsc --noEmit
pnpm start -- -c examples/echo-trace.loop.yaml -i hi   # run from source via tsx

Limitations (first cut)

  • Sequential only — no parallel nodes.
  • Adapters are exercised against stub binaries in the test suite; integration with real claude / opencode CLIs depends on their flags matching new_session_args / resume_session_args and on session_id_regex being correct for the version you run.
  • The otel hook requires the host process to install @opentelemetry/api and set up a tracer provider; absent that, it logs a warning and stays inert.