AGENTS.md@packages/coding-agent/src/core/extensions/builtin/loop · git:20260818.5893791 · 2026-08-18 · sha256 4a1344bdf0ff6341

AGENTS.md@packages/coding-agent/src/core/extensions/builtin/loop git:20260818.5893791A

Immutable. This exact content is served forever at /api/v1/blob/4a1344bdf0ff6341.

# builtin/loop

Fork-only builtin extension porting Claude Code's `/loop`: recurring (fixed-interval)
and self-paced (dynamic) scheduled prompts inside one session. A loop re-delivers a
prompt or a loop-file sentinel on a cadence; dynamic loops pick their own next delay
through the `schedule_wakeup` tool. Everything ships in this directory plus one
registration line in `builtin/index.ts`.

## FILES

```
loop/
├── index.ts        # Extension entry: real timers, store wiring, tick dispatch, lifecycle
├── command.ts      # /loop command registration: routes parsed invocations to the scheduler
├── types.ts        # Single type home: LoopState, CronEntry, lifecycle, payloads, sentinels
├── store.ts        # Atomic versioned per-session sidecar (temp file + rename, fail closed)
├── parse.ts        # Pure /loop argument grammar (subcommands, interval forms, prompt)
├── cron-planner.ts # normalizeInterval / describeCron / computeNextFireAt (pure)
├── loopfile.ts     # Loop-file resolution + content fingerprint (injected fs)
├── tick-prompt.ts  # buildTickMessage: sentinel expansion, full-vs-reminder decision (pure)
├── tools.ts        # schedule_wakeup: the only model-callable surface (flat TypeBox schema)
├── scheduler.ts    # Pure state machine: arm/fire/settle/pause/resume/stop/suspend/restore
└── status.ts       # Footer presenter: formatLoopStatus + 1s LoopStatusTicker
```

## PURITY SEAM

`scheduler.ts`, `parse.ts`, `cron-planner.ts`, `tick-prompt.ts`, and `status.ts`
(formatting) are pure. The scheduler never calls `Date.now` or `setTimeout`: a
`LoopClock` supplies `now` and a `LoopTimerPort` owns the single armed timeout per loop,
both injected by `index.ts`. `loopfile.ts` takes an injected `fs`/`path`/`cwd` bundle.
Only `index.ts` and `store.ts` touch the real world (timers, disk, extension events,
message dispatch). Every scheduling invariant is therefore testable with a fake clock and
zero real waiting. `types.ts` is the canonical type home; other modules re-export from it
rather than redeclaring.

## PERSISTENCE

`store.ts` mirrors the goal store's discipline: one sidecar file per session, strict
`version: 1` validation, atomic write via temp file + rename, and a promise tail
serializing every mutation so command, timer, tool, and lifecycle writes cannot
interleave. It FAILS CLOSED: unparseable or wrong-version state returns a typed error,
arms nothing, and never silently resets. Session custom entries are deliberately not the
authoritative store (a globally scanned "latest" entry can come from an abandoned
branch); the `loop-tick` entry exists only for attribution and noop folding.

## SCHEDULING INVARIANTS

- Coalescing: at most ONE queued or running tick per loop. A fire that lands while one
  is in flight sets `coalescedFirePending` instead of enqueuing a second delivery, so a
  sleeping laptop or a long turn never produces a tick storm. `nextFireAt` is always
  recomputed from `now`, never from the stale due time, so missed occurrences collapse
  into exactly one catch-up tick.
- 5-loop cap: at most `MAX_ACTIVE_LOOPS` (5) active loops per session; a further
  creation returns a typed rejection and leaves existing loops armed.
- Max-ticks valve: each loop carries a dispatched-tick budget (`DEFAULT_MAX_TICKS`,
  2000); exhausting it ends the loop with `tick_budget_exhausted` so a forgotten fast
  loop cannot spend without bound.
- Expiry: loops live at most 7 days from `createdAt`, checked at arm, fire, re-entry,
  and restore; a new wakeup never extends it.
- Keepalive: a two-strike device on dynamic loops only. When an attributed dynamic
  iteration ends without calling `schedule_wakeup`, the first omission burns one
  keepalive credit and arms a fallback wakeup (`SENPI_LOOP_KEEPALIVE_SECONDS`, default
  1200s, clamped 60-3600); the second consecutive omission ends the loop with
  `keepalive_exhausted`. Keepalive never applies after an ordinary user turn, and a user
  abort PAUSES the loop rather than ending it or counting as an omission.
- Provider/turn errors are never terminal for a loop.

## TICK DELIVERY

A tick never steers. When the session is idle, `index.ts` dispatches through
`sendUserMessage` with `expandPromptTemplates: true` so a slash payload reaches the real
command path; a busy session receives the tick as a follow-up. Sentinel payloads (the
four `<<...>>` loop/loop-file forms) follow a cache-friendly full-vs-reminder rule: the
long instruction block is delivered once as an anchor, and later ticks send a short
reminder pointing back at that anchored delivery, keeping the cached message prefix
stable. A changed loop-file fingerprint (content hash from `loopfile.ts`) re-anchors
with a fresh full delivery. Verbatim `prompt` payloads are always sent as-is.

## LIFECYCLE

Shutdown SUSPENDS, it never terminates: every senpi shutdown reason
(`quit|reload|new|resume|fork`) leaves the session resumable, so `onShutdown` cancels
timers, disposes the status ticker, and persists the snapshot without a terminal reason.
There is deliberately no `session_closed` end reason in `LoopEndReason`; the terminal
reasons are exactly `stopped | keepalive_exhausted | expired | tick_budget_exhausted |
error`. `restore` re-arms suspended loops on the next session start, re-checking expiry.
A store failure is not swallowed: affected loops end with `error` and the user is told,
because a schedule that cannot be persisted must not keep running.

## MODEL SURFACE

`schedule_wakeup` (`tools.ts`) is the only model-callable surface. Its TypeBox schema is
a flat object with no root union (several provider conversions rebuild schemas from
top-level `properties`, so a root `anyOf` would arrive empty; same reasoning as
`terminal/tools/monitor.ts`), and `delaySeconds` carries no schema bounds because the
executor clamps out-of-range integers (60-3600s) instead of rejecting them. All effects
go through an injected scheduler port; the tool mutates no state directly.