@0xtiby/looper

Standalone AI loop orchestration engine

Package details

extension

Install @0xtiby/looper from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@0xtiby/looper
Package
@0xtiby/looper
Version
0.3.1
Published
Apr 30, 2026
Downloads
319/mo · 253/wk
Author
0xtiby
License
MIT
Types
extension
Size
111.3 KB
Dependencies
3 dependencies · 4 peers
Pi manifest JSON
{
  "extensions": [
    "pi-extension"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

What Is Looper?

A standalone engine for looping an AI coding CLI against a prompt until it signals it's done:

  1. You give Looper a prompt and pick a CLI (claude, codex, opencode, or pi).
  2. Looper spawns the CLI, streams its output, and watches for a sentinel string.
  3. It re-spawns from scratch each iteration until the sentinel fires, maxIterations is reached, or the CLI exits non-zero.

Looper is stateless — every iteration is a fresh spawn with no shared conversation state. It has no opinions about specs, plans, trackers, git, or project structure. The prompt is the instruction.

Under the hood, Looper drives the CLIs via @0xtiby/spawner.

Prerequisites

Quick start

  1. Install the package:

    pnpm add @0xtiby/looper
    # or: npm install @0xtiby/looper
    
  2. Scaffold a .looper/config.json with sensible defaults:

    looper init
    
  3. Run a loop with an inline prompt:

    looper run -p "Refactor src/auth to remove dead code. Emit :::LOOPER_DONE::: when finished."
    

The package ships both a looper binary and a library (import { loop } from "@0xtiby/looper"). Both ESM and CommonJS are supported.

CLI

looper init

Creates .looper/config.json populated with the built-in defaults.

looper config

Prints the resolved config (defaults merged with your file).

looper run

Runs a loop. Exits 0 on clean completion, non-zero on CLI error, and 130 on SIGINT (Ctrl+C).

# Inline prompt, or a path to a file (auto-detected)
looper run -p ./plan.md --cli claude --max-iterations 5

# Or pipe in
cat plan.md | looper run --prompt-stdin
Flag Description
-p, --prompt <value> Inline string OR path to an existing file (auto-detected).
--prompt-stdin Read the prompt from stdin.
--cli <name> One of claude, codex, opencode, pi. Overrides config.
--model <name> Model override (e.g. opus, sonnet).
--max-iterations <n> Cap the number of iterations.
--sentinel <string> String the AI must emit to stop the loop.
--cwd <path> Working directory passed to the spawned CLI.
--var KEY=VALUE Template variable (repeatable — see Template variables).

looper resume

If a session is aborted with Ctrl+C, the session file is marked interrupted. Continue it with:

looper resume              # lists interrupted sessions
looper resume <session-id> # resumes the specific session

Resume reuses the original prompt, CLI, and model and runs the remaining iterations (up to maxIterations). New iterations are appended to both the session JSON and the transcript log. Stdin-origin prompts cannot be resumed.

Library usage

Basic

import { loop } from "@0xtiby/looper";

const result = await loop({
  cli: "claude",
  prompt: `
    1. Run: gh issue list --repo {{REPO}} --state open --json
    2. Pick the next unblocked issue labeled "ready".
    3. Implement it: write code, tests, commit, open a PR,
    4. Exit.
    When no unblocked issues remain, emit :::DONE:::
  `.trim(),
  cwd: process.cwd(),
  maxIterations: 20,
  sentinel: ":::DONE:::",
  vars: { REPO: "0xtiby/looper" },
});

console.log(result.stopReason); // "sentinel" | "max_iterations" | "error" | "aborted"

The library is stateless and does no file I/O. Callers own persistence; the looper CLI is a thin consumer of the library.

All options

import { loop } from "@0xtiby/looper";

const result = await loop({
  cli: "claude",
  prompt: "Fix the failing tests. Emit :::DONE::: when finished.",
  cwd: process.cwd(),
  model: "opus",
  maxIterations: 5,
  sentinel: ":::DONE:::",
  vars: { PROJECT: "my-app" },
  onOutput: (chunk) => process.stdout.write(chunk),
  signal: AbortSignal.timeout(60_000),
});

LoopOptions

Option Type Default Description
cli "claude" | "codex" | "opencode" | "pi" Required. The AI CLI to spawn.
prompt string Required. Prompt string passed to the CLI.
cwd string process.cwd() Working directory for the spawned CLI.
model string Model override (e.g. opus, sonnet).
maxIterations number 10 Hard cap on iterations.
sentinel string ":::LOOPER_DONE:::" String the CLI must emit to end the loop.
vars Record<string, string> {} Template variables for {{KEY}} substitution.
onOutput (chunk: string) => void Streaming callback for raw CLI stdout.
signal AbortSignal Aborts the loop between or during iterations.

LoopResult

Field Type Description
iterations IterationResult[] Per-iteration records, in order.
stopReason StopReason "sentinel" | "max_iterations" | "error" | "aborted".

IterationResult

Field Type Description
number number 1-indexed iteration number.
exitCode number Exit code of the spawned CLI.
sentinelDetected boolean Whether the sentinel was observed in stdout.
stdout string Full captured stdout for the iteration.
startedAt string ISO timestamp.
durationMs number Wall-clock duration.
tokensIn number Input tokens reported by the CLI.
tokensOut number Output tokens reported by the CLI.

Template variables

Prompts can reference {{VAR}} placeholders. Substitution is flat (values are not themselves re-substituted). Unknown placeholders are left as-is.

Built-in variables:

Name Value
ITERATION Current iteration number (1-indexed).
MAX_ITERATIONS Configured maxIterations.
SESSION_ID UUID of the session (CLI only).

Precedence (higher wins): --var KEY=VALUE CLI flag → config vars → built-in.

Config (.looper/config.json)

{
  "cli": "claude",
  "model": "opus",
  "maxIterations": 10,
  "sentinel": ":::LOOPER_DONE:::",
  "vars": {}
}

All fields are optional. Missing fields fall back to the defaults shown above. CLI flags on looper run override the resolved config.

Sessions

Each looper run (and resume) writes two files under .looper/sessions/<uuid>:

  • <uuid>.json — session metadata and per-iteration metrics.
  • <uuid>.log — raw CLI stdout, separated by --- ITERATION N [ISO_TIMESTAMP] --- markers.

Session state machine: activecompleted | interrupted. Session files are never auto-deleted.

Example session JSON:

{
  "id": "…",
  "prompt": "./plan.md",
  "cli": "claude",
  "model": "opus",
  "maxIterations": 10,
  "state": "completed",
  "startedAt": "2026-04-16T18:00:00.000Z",
  "completedAt": "2026-04-16T18:07:42.000Z",
  "stopReason": "sentinel",
  "iterations": [
    {
      "number": 1,
      "exitCode": 0,
      "durationMs": 45000,
      "tokensIn": 8000,
      "tokensOut": 12000,
      "sentinelDetected": true
    }
  ]
}

Pi integration

If you use pi, looper ships a built-in extension for fire-and-forget background runs inside Zellij panes or tmux sessions.

Install looper as a pi package:

pi install git:github.com/0xtiby/looper

Then in a pi session:

/looper-run

This opens an interactive wizard that walks you through picking a prompt (from .looper/*.md or writing a new one), choosing the AI CLI, and spawning looper in a background pane/session while you keep chatting with pi.

The LLM can also call the looper_run tool directly:

{
  "name": "looper_run",
  "parameters": {
    "prompt": "Refactor auth module. Emit :::LOOPER_DONE::: when finished.",
    "cli": "claude",
    "maxIterations": 5
  }
}

Features

  • Auto-detects Zellij (preferred) or tmux
  • Scans .looper/*.md for reusable prompts
  • Zellij: pane direction and floating mode support
  • tmux: new detached sessions

License

MIT