SLOPSHOPPER

autonomous-loop

A state-machine graph engine that drives a ticket through claim -> worktree -> coding -> evaluate -> review -> human approval, spawning and monitoring…

newpaneguardcommandprocesstimer
v0.1.0no licenseupdated 2026-09-26yai333/autonomous-loop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autonomous-loop
│ ┃ Autonomous Loop ✕ › fix the failing auth test and add an audit log call │ ┃ Autonomous Loop │ ┃ ██╗ ██████╗ ██████╗ ██████╗ ● autonomous-loop: autonomous-loop: coding #DEMO-1 (attempt 1/3) │ ┃ ██║ ██╔═══██╗██╔═══██╗██╔══██╗ ● autonomous-loop: autonomous-loop: coding #DEMO-2 (attempt 1/3) │ ┃ ██║ ██║ ██║██║ ██║██████╔╝ ⏺ Read(src/auth.ts) │ ┃ ██║ ██║ ██║██║ ██║██╔═══╝ ⎿ Read 6 lines │ ┃ ███████╗╚██████╔╝╚██████╔╝██║ ⏺ Update(src/auth.ts) │ ┃ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ⎿ Added 2 lines, removed 1 line │ ┃ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⏺ Bash(bun test) │ ┃ 2 active runs ⎿ 3 pass, 1 fail │ ┃ │ ┃ #DEMO-1 — coding (attempt 3/3) ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ branch: auto/demo-1-health-check │ ┃ model: opus ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ blocker: spawn resolved without an agentId › /autonomous-loop │ ┃ ⎿ autonomous-loop: Dashboard opened. │ ┃ [ Pause ] [ Stop ] ● autonomous-loop: autonomous-loop: #DEMO-1 needs a human answer -- o │ ┃ ● autonomous-loop: autonomous-loop: coding #DEMO-1 (attempt 2/3) │ ┃ history │ ┃ claimed --PREPARED--> coding │ ┃ coding --BLOCKER_FOUND--> waiting_for_huma │ ┃ waiting_for_human --ANSWERED--> coding │ ┃ coding --BLOCKER_FOUND--> waiting_for_huma │ ┃ waiting_for_human --ANSWERED--> coding │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Autonomous Loop
Autonomous Loop ██╗ ██████╗ ██████╗ ██████╗ ██║ ██╔═══██╗██╔═══██╗██╔══██╗ ██║ ██║ ██║██║ ██║██████╔╝ ██║ ██║ ██║██║ ██║██╔═══╝ ███████╗╚██████╔╝╚██████╔╝██║ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 2 active runs #DEMO-1 — coding (attempt 3/3) branch: auto/demo-1-health-check model: opus blocker: spawn resolved without an agentId [ Pause ] [ Stop ] history claimed --PREPARED--> coding coding --BLOCKER_FOUND--> waiting_for_human waiting_for_human --ANSWERED--> coding coding --BLOCKER_FOUND--> waiting_for_human waiting_for_human --ANSWERED--> coding #DEMO-2 — waiting_for_human (attempt 2/3) branch: auto/demo-2-format-duration model: opus blocker: spawn resolved without an agentId [ Pause ] [ Stop ] history claimed --PREPARED--> coding coding --BLOCKER_FOUND--> waiting_for_human waiting_for_human --ANSWERED--> coding coding --BLOCKER_FOUND--> waiting_for_human
README

autonomous-loop

Demo source code, not a maintained project. This repository accompanies an article on in-session loop engineering with Claude Code Mods. It is published as-is to show the pattern, and it is not regularly updated. Mods are an early-access Claude Code feature that can change between releases; this code was last checked against Claude Code 2.1.280, and it may need changes to run on later versions. Issues and pull requests are not actively monitored.

A ticket-to-review coding loop that runs inside a Claude Code session, built as a Claude Code Mod (TypeScript event hooks).

It claims tickets and gives each one its own git worktree. It spawns a coding agent, runs checks, then has a separate reviewer agent judge the result, and waits for a person to approve. Up to three tickets run at once, and a dashboard pane inside the session shows every ticket's progress with Pause, Stop, Approve and Reject buttons.

██╗      ██████╗  ██████╗ ██████╗
██║     ██╔═══██╗██╔═══██╗██╔══██╗
██║     ██║   ██║██║   ██║██████╔╝
██║     ██║   ██║██║   ██║██╔═══╝
███████╗╚██████╔╝╚██████╔╝██║
╚══════╝ ╚═════╝  ╚═════╝ ╚═╝
▃▅▆███▆▅▃▁▁▁▃▄▆███▆▅▃▁▁▁▃▄▆███▆▅▃▁

The tickets are demo tickets, and the checks are this repo's own tsc and npm test. To adapt the pattern to a real codebase, replace the ticket source (hooks/ticket.ts) and the checks (hooks/eval.ts) with that repository's own.

Requirements

  • Claude Code with function hooks (Mods). This is early access: the plugin only loads when the process is started with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Without it, the plugin is skipped silently.
  • Node.js 20+.

Run it

npm install

# Start Claude Code with the plugin loaded
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .

Inside the session, type /autonomous-loop to open the dashboard. A pane the plugin opens by itself only appears on terminals at least 144 columns wide; the command opens it at any width.

On startup the loop claims open tickets and starts working. Progress lines also appear in the transcript. The loop's state is kept in the plugin's own store (~/.claude/plugins/store/autonomous-loop_…json), so a new session resumes where the last one stopped.

If nothing happens, run with --debug-file /tmp/loop.log and search the log for autonomous-loop. A line saying the hooks module was "not loaded" means the environment variable is missing.

How it works

The graph is a transition table

Every status change goes through one pure function and this table (hooks/state.ts):

claim → prepare worktree → coding → evaluate ──pass──→ review ──pass──→ human approval → done
                              ▲        │                  │
                              └─retry──┘                  │
                              ▲                           │
                              └────────── fail ───────────┘
          evaluate / coding ── blocker ──→ waiting for human ── answered ──→ coding
StatusEventNext
claimedPREPAREDcoding
codingAGENT_FINISHED / BLOCKER_FOUNDevaluating / waiting_for_human
evaluatingPASS / RETRY / BLOCKER_FOUND / EXHAUSTEDreviewing / coding / waiting_for_human / blocked
waiting_for_humanANSWERED / CANCELLEDcoding / blocked
reviewingPASS / FAIL / EXHAUSTEDawaiting_approval / coding / blocked
awaiting_approvalAPPROVED / REJECTEDdone / blocked

Each step does its work and reports an event; the table decides what happens next.

Roles

  • Coding agent: a registered subagent type working in the ticket's worktree, with no web access and no way to ask the person directly (WebFetch, WebSearch and AskUserQuestion are denied). The first attempt uses sonnet; retries escalate to opus.
  • Reviewer agent: a separate registered type limited to Read, Grep, Glob and Bash, always on opus. The agent that writes a change never grades it.
  • Checks: deterministic commands (hooks/eval.ts). A failure is classified as retryable or as needing a person (hooks/blocker.ts).
  • Person: answers questions and approves or rejects from the dashboard.

Each prompt includes the ticket's title and description. A retry also sees the failed checks' output and the reviewer's reasons from the previous attempt.

The host pattern

Handlers receive the engine interface as $, but Claude Code refuses to load a module that passes $ around as a value: it statically lists every $.noun.method(...) call a plugin makes. So session.start wraps the calls it needs in a plain object of closures (hooks/host.ts), and passes that to the rest of the code:

const host: Host = {
  storeGet: (key) => $.store.get(key),
  run: (argv, init) => $.process.run(argv, init),
  spawnAgent: (args) => $.agent.spawn(args),
  // ...
}
void workflow.runWorkflow(host, { cwd: e.cwd })

Everything below index.ts depends only on the Host type, which is also what makes it testable with fakes.

Concurrency

runWorkflow keeps up to maxConcurrent tickets (default 3) in flight and refills a slot as soon as one finishes. The tickets are async functions in one process, sharing one host but each with its own state and worktree. Run state is saved as one whole snapshot after every step. Ticket-store updates go through a promise-chain lock, because each update is a read-modify-write of the whole list.

Project layout

PathWhat it is
.claude-plugin/plugin.jsonPlugin manifest
hooks/hooks.jsonMods entry point: { "modules": ["./index.ts"] }
hooks/index.tsRegisters hooks and agent types, builds host, starts the loop
hooks/host.tsThe Host type
hooks/workflow.tsPool scheduler and the per-ticket driver loop
hooks/state.tsWorkflowState, the transition table, model routing, persistence
hooks/agent.tsSpawns agents and waits for their turn.complete
hooks/ticket.tsDemo ticket store with claims and leases
hooks/worktree.tsGit worktree creation and diff stats
hooks/eval.ts, hooks/blocker.tsChecks, and classification of failures
hooks/approval.tsHuman approval and questions, per ticket
hooks/heartbeat.tsLease renewal for active tickets
hooks/policy.tsAgent type names and tool lists
hooks/dashboard/render.tsxThe dashboard pane
hooks/animation.tsThe LOOP banner animation

Checking the code

The plugin is typed against Claude Code's Mods type declarations, which are not included here because they are early access and change between releases. Generate them for your version by running /plugin-types in a Claude Code session opened in this folder. It writes .claude/types/, which tsconfig.json already includes. Then:

npm install
npm run typecheck
claude plugin validate .   # checks the plugin the way the engine will load it

Known limitations

  • Headless (-p) runs wait forever at a human gate. There is no dashboard to answer from, and the loop deliberately never approves on a person's behalf.
  • Worktrees are not cleaned up automatically. A finished ticket's worktree and branch stay for a person to review and merge.
  • Hooks do not fire for the plugin's own agents. Claude Code skips tool.call and turn.step for events the plugin itself caused, so tool restrictions are set when the agent types are registered, not in a tool.call hook.
  • One session at a time. The ticket-store lock protects a single session. Two sessions sharing the store would overwrite each other.
Source 14 files
hooks/index.ts 136 lines
1// hooks/index.ts — the plugin entry point: registers every hook and starts
2// the ticket loop when a session begins.
3
4import type { Register } from 'claude-code'
5import * as agent from './agent'
6import * as policy from './policy'
7import * as heartbeat from './heartbeat'
8import * as animation from './animation'
9import * as workflow from './workflow'
10import * as approval from './approval'
11import * as dashboard from './dashboard/render'
12import type { Host } from './host'
13
14// A repeat session.start (reconnect, hot reload) must drop the previous
15// subscription, or every state change triggers one extra redraw per reload.
16let unsubscribeWorkflow: (() => void) | undefined
17
18export const register: Register = (on) => {
19  on('session.start', async ($, e, next) => {
20    const result = await next(e)
21    await $.ui.open({ id: dashboard.PANE_ID, title: dashboard.PANE_TITLE })
22
23    // Built inline so every member is a literal `$.noun.method(...)` call;
24    // the object itself is plain data and can be passed anywhere (see host.ts).
25    const host: Host = {
26      now: () => $.clock.now(),
27      sleep: (ms) => $.clock.sleep(ms),
28      every: (ms, fn) => $.clock.every(ms, fn),
29      storeGet: (key) => $.store.get(key),
30      storeSet: (key, value) => $.store.set(key, value),
31      storeDelete: (key) => $.store.delete(key),
32      run: (argv, init) => $.process.run(argv, init),
33      fsExists: (path) => $.fs.exists(path),
34      spawnAgent: (args) => $.agent.spawn(args),
35      listAgents: () => $.agent.list(),
36      uiAsk: (question, options) => $.ui.ask(question, options),
37      uiOpen: (args) => $.ui.open(args),
38      uiInvalidate: (what) => $.ui.invalidate(what),
39      uiLog: (text, opts) => $.ui.log(text, opts),
40    }
41
42    // Registered before the workflow starts, so no spawn can race it. Errors
43    // are left to surface here rather than as unknown-subagent spawn denials.
44    await $.agent.register({
45      name: 'coding',
46      description: 'autonomous-loop: implements a ticket in the current worktree (internal use only)',
47      prompt:
48        'You are an autonomous coding agent. Implement the given ticket in the current worktree ' +
49        'using the available tools.',
50      disallowedTools: policy.CODING_DENIED_TOOLS,
51    })
52    await $.agent.register({
53      name: 'review',
54      description: 'autonomous-loop: reviews the diff in the current worktree (internal use only)',
55      prompt:
56        'You are an autonomous code reviewer. Inspect the diff and evidence in the current ' +
57        'worktree, then respond with PASS or FAIL.',
58      tools: policy.REVIEW_ALLOWED_TOOLS,
59    })
60
61    // A pane the plugin opens on its own waits undrawn below 144 terminal
62    // columns; one opened from a command the person typed is placed at any width.
63    await $.command.register({
64      name: 'autonomous-loop',
65      description: 'Open the autonomous-loop dashboard',
66    })
67
68    heartbeat.startHeartbeat(host, {
69      // Every active ticket needs its lease renewed, not just one.
70      ticketIds: () => workflow.currentStates().map((s) => s.ticketId),
71      onBeat: () => $.ui.invalidate('ui.render'),
72    })
73    animation.startAnimationTicker(
74      host,
75      // One shared animation for the pane: on while ANY ticket is coding/reviewing.
76      () => workflow.currentStates().some((s) => s.status === 'coding' || s.status === 'reviewing'),
77      () => $.ui.invalidate('ui.render'),
78    )
79    unsubscribeWorkflow?.()
80    unsubscribeWorkflow = workflow.subscribe(() => $.ui.invalidate('ui.render'))
81    void workflow.runWorkflow(host, { cwd: e.cwd }).catch((err) =>
82      $.ui.log(`autonomous-loop: ${String(err)}`, { to: 'debug' }))
83    return result
84  })
85
86  on('session.end', async ($, e, next) => {
87    // Stop this process's timers and counters. Worktrees and leases are left
88    // alone on purpose: an unfinished ticket resumes from them next session.
89    heartbeat.stopHeartbeat()
90    animation.stopAnimationTicker()
91    policy.resetStats()
92    return next(e)
93  })
94
95  // Every hook is written as a function literal at its on() call site: the
96  // engine scans the source before loading and refuses a hook passed as a
97  // factory's return value. Factories are called inside the literal instead.
98
99  // The two agent types are for this plugin's own spawns only; hide them
100  // from the main model's agent list.
101  on('agent.offer', { agent: policy.CODING_AGENT_TYPE }, () => ({ isOffered: false }))
102  on('agent.offer', { agent: policy.REVIEW_AGENT_TYPE }, () => ({ isOffered: false }))
103
104  on('turn.complete', ($, e, next) => agent.createTurnCompleteHook()(e, next))
105  // turn.step and tool.call are skipped for events the plugin's own agents
106  // raise (the engine does not re-enter a plugin), so these only observe
107  // other activity. Tool limits are enforced by agent.register above.
108  on('turn.step', async function* ($, e, next) {
109    return yield* agent.createTurnStepHook()(e, next)
110  })
111  on('tool.call', ($, e, next) =>
112    policy.createToolCallHook({
113      agentKindOf: agent.agentKindOf,
114      observe: agent.observeToolCall,
115    })(e, next),
116  )
117  // Opened from a command, so the pane is placed at any width. Re-opening an
118  // already-open pane is a no-op. This hook is the command, so no next().
119  on('command.run', { command: 'autonomous-loop' }, async ($) => {
120    await $.ui.open({ id: dashboard.PANE_ID, title: dashboard.PANE_TITLE })
121    return { text: 'Dashboard opened.' }
122  })
123
124  on('ui.render', { component: 'Pane' }, ($, e, next) => {
125    if (!dashboard.isOwnPane(e)) return next(e)
126    // Resolved here and passed down as elements — `$` itself never leaves this hook.
127    const elements = $.ui.resolve(e)
128    return dashboard.renderDashboard(elements, e, {
129      states: workflow.currentStates(),
130      emit: (ev) => workflow.submitEvent(ev),
131      awaitingApproval: (ticketId) => approval.isAwaitingApproval(ticketId),
132      awaitingHuman: (ticketId) => approval.isAwaitingHuman(ticketId),
133    })
134  })
135}
136
hooks/agent.ts 368 lines
1// hooks/agent.ts — spawns the coding and review subagents, tracks each by
2// agentId, and resolves its run from the matching turn.complete. Exports hook
3// factories only; index.ts does all the on(...) registration.
4
5import type {
6  EngineInterface,
7  Frozen,
8  Hook,
9  Timer,
10  ToolCallInput,
11  TurnCompleteInput,
12  TurnCompleteReason,
13} from 'claude-code'
14import type { Host } from './host'
15import { pickModel } from './state'
16import type { ModelTier, WorkflowState } from './state'
17import { CODING_AGENT_TYPE, REVIEW_AGENT_TYPE } from './policy'
18
19export type AgentKind = 'coding' | 'review'
20
21export type AgentProgress = {
22  agentId: string
23  kind: AgentKind
24  model: ModelTier
25  steps: number
26  toolCalls: readonly string[]
27  lastText: string
28  startedAtMs: number
29}
30
31export type AgentRunResult = {
32  kind: AgentKind
33  model: ModelTier
34  agentId?: string
35  answer: string
36  isAborted: boolean
37  reason?: TurnCompleteReason
38  steps: number
39  toolCalls: readonly string[]
40  durationMs: number
41  denied?: string
42}
43
44export type SpawnRequest = {
45  kind: AgentKind
46  state: WorkflowState
47  prompt: string
48  description: string
49  cwd?: string
50  subagentType?: string
51}
52
53// Registered by index.ts with policy.ts's tool lists (see policy.ts).
54export const CODING_SUBAGENT_TYPE = CODING_AGENT_TYPE
55export const REVIEW_SUBAGENT_TYPE = REVIEW_AGENT_TYPE
56
57// ---------------------------------------------------------------------------
58// Internal tracking state.
59// ---------------------------------------------------------------------------
60
61type PendingRun = {
62  agentId: string
63  kind: AgentKind
64  model: ModelTier
65  ticketId: string
66  resolve: (result: AgentRunResult) => void
67}
68
69const progress = new Map<string, AgentProgress>()
70const pending = new Map<string, PendingRun>()
71const liveness = new Map<string, Timer>()
72
73// An agent that dies without finishing a turn never sends turn.complete, so
74// runAgent polls $.agent.list() to avoid waiting forever.
75const LIVENESS_POLL_MS = 15_000
76
77function stopLivenessWatch(agentId: string): void {
78  liveness.get(agentId)?.cancel()
79  liveness.delete(agentId)
80}
81
82/**
83 * Settles the pending run as aborted if the engine reports the agent stopped
84 * running before any turn.complete. The agent must be seen `running` once
85 * first, so a listing that lags a fresh spawn is not mistaken for a death.
86 */
87function startLivenessWatch(host: Host, agentId: string): void {
88  let sawRunning = false
89  const timer = host.every(LIVENESS_POLL_MS, () => {
90    void (async () => {
91      const run = pending.get(agentId)
92      if (run === undefined) {
93        stopLivenessWatch(agentId)
94        return
95      }
96      const infos = await host.listAgents()
97      const info = infos.find((i) => i.id === agentId)
98      if (info === undefined) {
99        if (sawRunning) {
100          settleDead(agentId, 'agent is no longer listed by $.agent.list() and no turn.complete arrived')
101        }
102        return
103      }
104      if (info.status === 'running') {
105        sawRunning = true
106        return
107      }
108      settleDead(
109        agentId,
110        `agent ended with status '${info.status}' and no turn.complete arrived`,
111      )
112    })()
113  })
114  liveness.set(agentId, timer)
115}
116
117/** Settles a run the engine reports as no longer running, absent a turn.complete. */
118function settleDead(agentId: string, reason: string): void {
119  const run = pending.get(agentId)
120  if (run === undefined) return
121  stopLivenessWatch(agentId)
122  const p = progress.get(agentId)
123  run.resolve({
124    kind: run.kind,
125    model: run.model,
126    agentId,
127    answer: '',
128    isAborted: true,
129    reason: 'aborted',
130    steps: p?.steps ?? 0,
131    toolCalls: p?.toolCalls ?? [],
132    durationMs: 0,
133    denied: reason,
134  })
135}
136
137// ---------------------------------------------------------------------------
138// Spawning.
139// ---------------------------------------------------------------------------
140
141export async function runAgent(
142  host: Host,
143  request: SpawnRequest,
144): Promise<AgentRunResult> {
145  // Route by `kind`, not by state.status, so a review always gets the review model.
146  const model: ModelTier = pickModel({
147    status: request.kind === 'review' ? 'reviewing' : 'coding',
148    attempt: request.state.attempt,
149  })
150
151  const startedAtMs = await host.now()
152
153  const spawned = await host.spawnAgent({
154    prompt: request.prompt,
155    description: request.description,
156    subagentType:
157      request.subagentType ??
158      (request.kind === 'review' ? REVIEW_SUBAGENT_TYPE : CODING_SUBAGENT_TYPE),
159    model,
160    cwd: request.cwd ?? request.state.worktreePath,
161  })
162
163  if (spawned.deny !== undefined) {
164    return {
165      kind: request.kind,
166      model,
167      answer: '',
168      isAborted: false,
169      steps: 0,
170      toolCalls: [],
171      durationMs: 0,
172      denied: spawned.deny,
173    }
174  }
175
176  const agentId = spawned.agentId
177  if (agentId === undefined) {
178    return {
179      kind: request.kind,
180      model,
181      answer: '',
182      isAborted: false,
183      steps: 0,
184      toolCalls: [],
185      durationMs: 0,
186      denied: 'spawn resolved without an agentId',
187    }
188  }
189
190  // Track before awaiting, so an early turn.complete is not missed.
191  progress.set(agentId, {
192    agentId,
193    kind: request.kind,
194    model,
195    steps: 0,
196    toolCalls: [],
197    lastText: '',
198    startedAtMs,
199  })
200
201  const result = await new Promise<AgentRunResult>((resolve) => {
202    pending.set(agentId, {
203      agentId,
204      kind: request.kind,
205      model,
206      ticketId: request.state.ticketId,
207      resolve: (result) => {
208        pending.delete(agentId)
209        progress.delete(agentId)
210        stopLivenessWatch(agentId)
211        resolve(result)
212      },
213    })
214    startLivenessWatch(host, agentId)
215  })
216  return result
217}
218
219// ---------------------------------------------------------------------------
220// Hook factories.
221// ---------------------------------------------------------------------------
222
223/**
224 * Only `reason === 'answer'` is a real completion. Every other reason becomes
225 * a `denied` string, so workflow.ts routes it to a blocker, not a finish.
226 */
227function deniedReasonFor(e: Frozen<TurnCompleteInput>): string | undefined {
228  switch (e.reason) {
229    case 'answer':
230      return undefined
231    case 'refusal':
232      // Either field may be null.
233      return `model refused: ${e.refusal.category ?? 'unknown'}${
234        e.refusal.explanation ? ` — ${e.refusal.explanation}` : ''
235      }`
236    case 'aborted':
237      return 'turn aborted (interrupted)'
238    case 'error':
239      return 'turn ended in error (retries exhausted or context limit)'
240  }
241}
242
243// Hook bodies take no `$`: the loader refuses any function that receives `$`
244// from outside its own on(...) call site.
245type TurnCompleteHookBody = (
246  e: Parameters<Hook<'turn.complete'>>[1],
247  next: Parameters<Hook<'turn.complete'>>[2],
248) => ReturnType<Hook<'turn.complete'>>
249
250/** on('turn.complete', ...): resolves the deferred for e.agentId, then `return next(e)`. */
251export function createTurnCompleteHook(): TurnCompleteHookBody {
252  return (e, next) => {
253    if (e.agentId !== undefined) {
254      const run = pending.get(e.agentId)
255      if (run !== undefined) {
256        const p = progress.get(e.agentId)
257        run.resolve({
258          kind: run.kind,
259          model: run.model,
260          agentId: e.agentId,
261          answer: e.answer,
262          isAborted: e.isAborted,
263          reason: e.reason,
264          steps: p?.steps ?? 0,
265          toolCalls: p?.toolCalls ?? [],
266          durationMs: e.durationMs,
267          denied: deniedReasonFor(e),
268        })
269      }
270    }
271    return next(e)
272  }
273}
274
275type TurnStepHookBody = (
276  e: Parameters<Hook<'turn.step'>>[1],
277  next: Parameters<Hook<'turn.step'>>[2],
278) => ReturnType<Hook<'turn.step'>>
279
280/** on('turn.step', ...): async generator; `return yield* next(e)` after counting the step for e.agentId. */
281export function createTurnStepHook(): TurnStepHookBody {
282  return async function* (e, next) {
283    const result = yield* next(e)
284    if (e.agentId !== undefined) {
285      const p = progress.get(e.agentId)
286      if (p !== undefined) {
287        p.steps += 1
288        if (result?.answer) p.lastText = result.answer
289      }
290    }
291    return result
292  }
293}
294
295/** Called by policy.ts's tool.call hook (agent.ts registers no tool.call hook of its own). */
296export function observeToolCall(e: Frozen<ToolCallInput>): void {
297  if (e.agentId === undefined) return
298  const p = progress.get(e.agentId)
299  if (p === undefined) return
300  p.toolCalls = [...p.toolCalls, e.tool]
301}
302
303// ---------------------------------------------------------------------------
304// Read-only accessors.
305// ---------------------------------------------------------------------------
306
307export function agentKindOf(agentId: string | undefined): AgentKind | undefined {
308  if (agentId === undefined) return undefined
309  return progress.get(agentId)?.kind
310}
311
312export function isTrackedAgent(agentId: string | undefined): boolean {
313  return agentId !== undefined && progress.has(agentId)
314}
315
316export function trackedAgents(): readonly AgentProgress[] {
317  return [...progress.values()]
318}
319
320export function progressOf(agentId: string): AgentProgress | undefined {
321  return progress.get(agentId)
322}
323
324// ---------------------------------------------------------------------------
325// Teardown.
326// ---------------------------------------------------------------------------
327
328function settleAbandoned(run: PendingRun, reason: string): void {
329  stopLivenessWatch(run.agentId)
330  run.resolve({
331    kind: run.kind,
332    model: run.model,
333    agentId: run.agentId,
334    answer: '',
335    isAborted: true,
336    reason: 'aborted',
337    steps: progress.get(run.agentId)?.steps ?? 0,
338    toolCalls: progress.get(run.agentId)?.toolCalls ?? [],
339    durationMs: 0,
340    denied: reason,
341  })
342  pending.delete(run.agentId)
343  progress.delete(run.agentId)
344}
345
346/** Aborts the agents of one ticket (on its Stop), never another ticket's. */
347export function abandonForTicket(ticketId: string, reason: string): void {
348  for (const run of [...pending.values()]) {
349    if (run.ticketId === ticketId) settleAbandoned(run, reason)
350  }
351}
352
353/** Aborts every pending agent, on full teardown. */
354export function abandonAll(reason: string): void {
355  for (const run of [...pending.values()]) settleAbandoned(run, reason)
356}
357
358/** Convenience over $.agent.list(), filtered to this plugin's tracked ids. */
359export async function liveAgentStatuses(
360  host: Host,
361): Promise<Record<string, string>> {
362  const infos = await host.listAgents()
363  const ids = new Set(progress.keys())
364  return Object.fromEntries(
365    infos.filter((i) => ids.has(i.id)).map((i) => [i.id, i.status]),
366  )
367}
368
hooks/policy.ts 96 lines
1// hooks/policy.ts — tool permissions for the loop's coding and review agents.
2
3import type { Frozen, Hook, ToolCallInput } from 'claude-code'
4import type { AgentKind } from './agent'
5
6export type PolicyDecision =
7  | { allow: true; reason?: undefined }
8  | { allow: false; reason: string }
9
10export type PolicyStats = {
11  calls: number
12  denied: number
13  byTool: Record<string, number>
14  deniedByTool: Record<string, number>
15}
16
17export type PolicyDeps = {
18  agentKindOf: (agentId: string | undefined) => AgentKind | undefined
19  observe: (e: Frozen<ToolCallInput>) => void
20}
21
22/** Tools a 'coding' subagent may never call. */
23export const CODING_DENIED_TOOLS: readonly string[] = ['WebFetch', 'WebSearch', 'AskUserQuestion']
24
25/** Tools a 'review' subagent may call; anything else is denied for kind === 'review'. */
26export const REVIEW_ALLOWED_TOOLS: readonly string[] = ['Read', 'Grep', 'Glob', 'Bash']
27
28// The real enforcement is at registration: index.ts registers these agent
29// types with the lists above, and the engine applies them itself. A tool.call
30// hook cannot do it -- tool.call and turn.step never fire for agents this
31// plugin spawned. createToolCallHook below still polices every other caller.
32export const PLUGIN_NAME = 'autonomous-loop'
33export const CODING_AGENT_TYPE = `${PLUGIN_NAME}:coding`
34export const REVIEW_AGENT_TYPE = `${PLUGIN_NAME}:review`
35
36function emptyStats(): PolicyStats {
37  return { calls: 0, denied: 0, byTool: {}, deniedByTool: {} }
38}
39
40let current: PolicyStats = emptyStats()
41
42/** Pure: no $, no I/O. Calls outside a tracked agent (agentKind undefined) always allow. */
43export function decide(args: {
44  tool: string
45  agentKind: AgentKind | undefined
46}): PolicyDecision {
47  const { tool, agentKind } = args
48  if (agentKind === undefined) return { allow: true }
49  if (agentKind === 'coding' && CODING_DENIED_TOOLS.includes(tool)) {
50    return { allow: false, reason: `coding agents may not call ${tool}` }
51  }
52  if (agentKind === 'review' && !REVIEW_ALLOWED_TOOLS.includes(tool)) {
53    return {
54      allow: false,
55      reason: `review agents may only use: ${REVIEW_ALLOWED_TOOLS.join(', ')}`,
56    }
57  }
58  return { allow: true }
59}
60
61type ToolCallHookBody = (
62  e: Parameters<Hook<'tool.call'>>[1],
63  next: Parameters<Hook<'tool.call'>>[2],
64) => ReturnType<Hook<'tool.call'>>
65
66/** on('tool.call', ...): deps.observe(e), then `{ deny }` or `return next(e)`. */
67export function createToolCallHook(deps: PolicyDeps): ToolCallHookBody {
68  return (e, next) => {
69    deps.observe(e)
70
71    current.calls += 1
72    current.byTool[e.tool] = (current.byTool[e.tool] ?? 0) + 1
73
74    const decision = decide({ tool: e.tool, agentKind: deps.agentKindOf(e.agentId) })
75    if (!decision.allow) {
76      current.denied += 1
77      current.deniedByTool[e.tool] = (current.deniedByTool[e.tool] ?? 0) + 1
78      return { deny: decision.reason }
79    }
80    return next(e)
81  }
82}
83
84export function stats(): PolicyStats {
85  return {
86    calls: current.calls,
87    denied: current.denied,
88    byTool: { ...current.byTool },
89    deniedByTool: { ...current.deniedByTool },
90  }
91}
92
93export function resetStats(): void {
94  current = emptyStats()
95}
96
hooks/heartbeat.ts 107 lines
1// hooks/heartbeat.ts — renews the lease on every actively worked ticket.
2
3import type { Timer } from 'claude-code'
4
5import type { Host } from './host'
6import { getTicket, renewLease } from './ticket'
7
8export type HeartbeatHandle = {
9  cancel: () => void
10}
11
12export type HeartbeatOptions = {
13  intervalMs?: number
14  leaseMs?: number
15  /** Every ticket currently being worked, read fresh at each tick. */
16  ticketIds?: () => readonly string[]
17  onBeat?: (nowMs: number) => void
18}
19
20export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000
21export const DEFAULT_LEASE_MS = 120_000
22
23// Heartbeat's own key, written by nothing else, so it has no writer to race.
24const HEARTBEAT_STORE_KEY = 'autonomous-loop/heartbeat'
25
26let timer: Timer | undefined
27let beats = 0
28let lastBeat: number | undefined
29
30async function beat(
31  host: Host,
32  options: HeartbeatOptions,
33  leaseMs: number,
34): Promise<void> {
35  const nowMs = await host.now()
36  const ticketIds = options.ticketIds?.() ?? []
37
38  for (const ticketId of ticketIds) {
39    // Re-check just before renewing: a ticket already done/blocked must never
40    // have its lease extended.
41    const ticket = await getTicket(host, ticketId).catch(() => undefined)
42    if (ticket && ticket.status !== 'done' && ticket.status !== 'blocked') {
43      await renewLease(host, ticketId, leaseMs).catch(() => undefined)
44    }
45  }
46
47  // Never written through saveRuns: $.store has no compare-and-set, so
48  // sharing workflow.ts's runs key would race its persist() writes.
49  if (ticketIds.length > 0) {
50    await host.storeSet(HEARTBEAT_STORE_KEY, {
51      ticketIds,
52      leaseExpiresAtMs: nowMs + leaseMs,
53    })
54  }
55
56  beats += 1
57  lastBeat = nowMs
58  options.onBeat?.(nowMs)
59}
60
61export function startHeartbeat(
62  host: Host,
63  options: HeartbeatOptions = {},
64): HeartbeatHandle {
65  if (timer) {
66    timer.cancel()
67    timer = undefined
68  }
69
70  const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
71  const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS
72
73  timer = host.every(intervalMs, () => {
74    void beat(host, options, leaseMs)
75  })
76
77  return { cancel: () => stopHeartbeat() }
78}
79
80export function stopHeartbeat(): void {
81  timer?.cancel()
82  timer = undefined
83}
84
85/** Full teardown, including the beat counters (for test isolation). */
86export function reset(): void {
87  stopHeartbeat()
88  beats = 0
89  lastBeat = undefined
90}
91
92export function isBeating(): boolean {
93  return timer !== undefined
94}
95
96export function lastBeatMs(): number | undefined {
97  return lastBeat
98}
99
100export function beatCount(): number {
101  return beats
102}
103
104export function currentTimer(): Timer | undefined {
105  return timer
106}
107
hooks/animation.ts 79 lines
1// hooks/animation.ts — the dashboard's LOOP banner: the word in a block font,
2// with a row of blocks underneath that rolls as a wave while any agent works.
3// Purely cosmetic UI state, kept out of the persisted WorkflowState.
4
5import type { Timer } from 'claude-code'
6import type { Host } from './host'
7
8/** 6 letter rows + 1 block row underneath. */
9export type Frame = readonly string[]
10
11const BANNER: readonly string[] = [
12  '██╗      ██████╗  ██████╗ ██████╗ ',
13  '██║     ██╔═══██╗██╔═══██╗██╔══██╗',
14  '██║     ██║   ██║██║   ██║██████╔╝',
15  '██║     ██║   ██║██║   ██║██╔═══╝ ',
16  '███████╗╚██████╔╝╚██████╔╝██║     ',
17  '╚══════╝ ╚═════╝  ╚═════╝ ╚═╝     ',
18]
19/** Display width of every banner row, in cells. */
20export const BANNER_WIDTH = [...BANNER[0]!].length
21/** Block heights, lowest to highest. */
22const LEVELS = '▁▂▃▄▅▆▇█'
23/** Columns per wave crest; the frame cycle length too, so the wave rolls seamlessly. */
24const WAVE_PERIOD = 12
25const TICK_MS = 120
26
27let timer: Timer | undefined
28let frameIndex = 0
29let wasWorking = false
30
31function waveRow(tick: number): string {
32  let row = ''
33  for (let col = 0; col < BANNER_WIDTH; col++) {
34    const phase = ((col - tick) / WAVE_PERIOD) * 2 * Math.PI
35    const level = Math.round(((Math.sin(phase) + 1) / 2) * (LEVELS.length - 1))
36    row += LEVELS[level]
37  }
38  return row
39}
40
41const IDLE_FRAME: Frame = [...BANNER, LEVELS[0]!.repeat(BANNER_WIDTH)]
42
43/** The current frame to draw; a flat block row whenever nothing is actively running. */
44export function currentFrame(): Frame {
45  return wasWorking ? [...BANNER, waveRow(frameIndex)] : IDLE_FRAME
46}
47
48/**
49 * Starts the banner's ticker (a repeat call is a no-op). Each tick reads
50 * `isWorking()` fresh instead of reacting to transitions, so a session that
51 * resumes mid-'coding' still animates. While idle a tick does nothing.
52 */
53export function startAnimationTicker(
54  host: Host,
55  isWorking: () => boolean,
56  invalidate: () => void,
57): void {
58  if (timer !== undefined) return
59  timer = host.every(TICK_MS, () => {
60    const working = isWorking()
61    if (!working) {
62      if (wasWorking) invalidate() // one final redraw back to the still word
63      wasWorking = false
64      frameIndex = 0
65      return
66    }
67    wasWorking = true
68    frameIndex = (frameIndex + 1) % WAVE_PERIOD
69    invalidate()
70  })
71}
72
73export function stopAnimationTicker(): void {
74  timer?.cancel()
75  timer = undefined
76  frameIndex = 0
77  wasWorking = false
78}
79
hooks/workflow.ts 655 lines
1// hooks/workflow.ts -- the loop itself: drives each ticket through the
2// transition table, and runs up to `maxConcurrent` tickets at once.
3
4import type { Host } from './host'
5import {
6  createInitialState,
7  isTerminal,
8  nextStatus,
9  loadRuns,
10  saveRuns,
11  type WorkflowEvent,
12  type WorkflowState,
13} from './state'
14import { getTicket, claimTicket, updateTicket, type Ticket } from './ticket'
15import { repoRoot, prepareWorktree, worktreeExists, diffStat } from './worktree'
16import { evaluateAttempt, type EvalReport } from './eval'
17import { classifyFailure, toWorkflowEvent } from './blocker'
18import {
19  requestApproval,
20  askHuman,
21  resolveHumanAnswer,
22  resolveApproval,
23  cancelForTicket,
24} from './approval'
25import { runAgent, abandonForTicket } from './agent'
26
27export type ExternalEvent =
28  | { kind: 'HUMAN_ANSWERED'; ticketId: string; answer: string }
29  | { kind: 'APPROVED'; ticketId: string }
30  | { kind: 'REJECTED'; ticketId: string; reason?: string }
31  | { kind: 'PAUSE'; ticketId: string }
32  | { kind: 'RESUME'; ticketId: string }
33  | { kind: 'STOP'; ticketId: string }
34
35export type WorkflowOptions = {
36  cwd: string
37  ticketId?: string
38  maxAttempts?: number
39  resume?: boolean
40  /** How many tickets the pool works at once. Forced to 1 when `ticketId` is set. */
41  maxConcurrent?: number
42}
43
44export const DEFAULT_MAX_CONCURRENT = 3
45
46export type TransitionError = {
47  from: WorkflowState['status']
48  event: WorkflowEvent
49  message: string
50}
51
52// ---------------------------------------------------------------------------
53// Pool state. `current` (ticketId -> WorkflowState) is authoritative; the
54// store is a mirror written by persist() and never read back mid-run.
55// ---------------------------------------------------------------------------
56
57let current = new Map<string, WorkflowState>()
58let running = false
59let listeners = new Set<(state: WorkflowState) => void>()
60let pendingExternal: Array<{ kind: 'PAUSE' | 'RESUME' | 'STOP'; ticketId: string }> = []
61// Last status logged per ticket, so same-status writes (e.g. pause/resume) don't repeat a transcript line.
62let lastLoggedStatusByTicket = new Map<string, string>()
63
64// ---------------------------------------------------------------------------
65// applyTransition -- pure, and the only place state.status changes, so the
66// whole graph lives in one table that can be tested without an engine.
67// ---------------------------------------------------------------------------
68
69export function applyTransition(
70  state: WorkflowState,
71  event: WorkflowEvent,
72  nowMs: number,
73  note?: string,
74): WorkflowState {
75  const to = nextStatus(state.status, event)
76  if (to === undefined) {
77    const message = `autonomous-loop: no transition from ${state.status} on ${event}`
78    const err = new Error(message) as Error & TransitionError
79    err.from = state.status
80    err.event = event
81    err.message = message
82    throw err
83  }
84  // attempt counts coding attempts: every edge that lands on 'coding'
85  // (PREPARED, RETRY, ANSWERED, FAIL) starts a new one.
86  const attempt = to === 'coding' ? state.attempt + 1 : state.attempt
87  const entry = {
88    from: state.status,
89    event,
90    to,
91    attempt,
92    atMs: nowMs,
93    ...(note === undefined ? {} : { note }),
94  }
95  return {
96    ...state,
97    status: to,
98    attempt,
99    history: [...state.history, entry],
100    updatedAtMs: nowMs,
101  }
102}
103
104// ---------------------------------------------------------------------------
105// Persistence + subscriber fan-out.
106// ---------------------------------------------------------------------------
107
108/** One transcript line per status. The switch is exhaustive, so a new status without a case fails to compile. */
109export function describeTransition(state: WorkflowState): string {
110  const ticket = state.ticketId ? `#${state.ticketId}` : '(no ticket)'
111  const attempt = `attempt ${state.attempt}/${state.maxAttempts}`
112  switch (state.status) {
113    case 'claimed':
114      return `autonomous-loop: claimed ${ticket}`
115    case 'coding':
116      return `autonomous-loop: coding ${ticket} (${attempt})`
117    case 'evaluating':
118      return `autonomous-loop: evaluating ${ticket}`
119    case 'waiting_for_human':
120      return `autonomous-loop: ${ticket} needs a human answer -- open the dashboard`
121    case 'reviewing':
122      return `autonomous-loop: reviewing ${ticket}`
123    case 'awaiting_approval':
124      return `autonomous-loop: ${ticket} is awaiting your approval -- open the dashboard`
125    case 'done':
126      return `autonomous-loop: ${ticket} done`
127    case 'blocked':
128      return `autonomous-loop: ${ticket} blocked${state.blockerReason ? ` (${state.blockerReason})` : ''}`
129  }
130}
131
132async function persist(host: Host, state: WorkflowState): Promise<WorkflowState> {
133  current.set(state.ticketId, state)
134  await saveRuns(host, current)
135  for (const listener of listeners) listener(state)
136
137  // No `{ to: 'debug' }`: status changes are the one thing logged to the visible transcript.
138  if (lastLoggedStatusByTicket.get(state.ticketId) !== state.status) {
139    lastLoggedStatusByTicket.set(state.ticketId, state.status)
140    await host.uiLog(describeTransition(state))
141  }
142
143  return state
144}
145
146// ---------------------------------------------------------------------------
147// submitEvent — the synchronous external-event inbox.
148// ---------------------------------------------------------------------------
149
150// Synchronous and engine-free, so the dashboard's buttons can call it directly.
151export function submitEvent(event: ExternalEvent): void {
152  switch (event.kind) {
153    case 'HUMAN_ANSWERED':
154      resolveHumanAnswer(event.ticketId, event.answer)
155      break
156    case 'APPROVED':
157      resolveApproval(event.ticketId, 'approved')
158      break
159    case 'REJECTED':
160      resolveApproval(event.ticketId, 'rejected', event.reason)
161      break
162    case 'PAUSE':
163      pendingExternal.push({ kind: 'PAUSE', ticketId: event.ticketId })
164      break
165    case 'RESUME':
166      pendingExternal.push({ kind: 'RESUME', ticketId: event.ticketId })
167      break
168    case 'STOP':
169      // Scoped to this ticket: never abort another ticket's agent or pending question.
170      pendingExternal.push({ kind: 'STOP', ticketId: event.ticketId })
171      abandonForTicket(event.ticketId, 'stopped by user')
172      cancelForTicket(event.ticketId, 'stopped by user')
173      break
174  }
175}
176
177// ---------------------------------------------------------------------------
178// Driver loop helpers.
179// ---------------------------------------------------------------------------
180
181async function startFresh(host: Host, options: WorkflowOptions): Promise<WorkflowState> {
182  const ticket = await claimTicket(host, options.ticketId !== undefined ? { ticketId: options.ticketId } : {})
183  if (ticket === undefined) {
184    throw new Error('autonomous-loop: no claimable ticket')
185  }
186  const nowMs = await host.now()
187  return createInitialState({
188    ticketId: ticket.id,
189    nowMs,
190    ...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),
191    branch: ticket.branch,
192  })
193}
194
195/**
196 * Applies the queued events addressed to this ticket, leaving the rest. The
197 * two filters run with no `await` between them, so other drivers can't interleave.
198 */
199async function drainExternal(host: Host, state: WorkflowState): Promise<WorkflowState> {
200  const mine = pendingExternal.filter((ev) => ev.ticketId === state.ticketId)
201  pendingExternal = pendingExternal.filter((ev) => ev.ticketId !== state.ticketId)
202
203  let next = state
204  for (const ev of mine) {
205    if (ev.kind === 'PAUSE') {
206      next = await persist(host, { ...next, paused: true })
207    } else if (ev.kind === 'RESUME') {
208      next = await persist(host, { ...next, paused: false })
209    } else if (ev.kind === 'STOP') {
210      next = await persist(host, { ...next, stopped: true })
211    }
212  }
213  return next
214}
215
216/**
217 * Re-creates the ticket's worktree if it no longer exists. A resumed run may
218 * point at a directory removed since (crash, cleanup, `git worktree prune`),
219 * and an agent spawned with a missing `cwd` falls back to the session's own
220 * directory -- editing the main checkout instead of the isolated worktree.
221 */
222export async function ensureWorktree(
223  host: Host,
224  state: WorkflowState,
225  options: WorkflowOptions,
226): Promise<WorkflowState> {
227  if (state.worktreePath === undefined) return state
228  if (await worktreeExists(host, state.worktreePath)) return state
229  const root = await repoRoot(host, options.cwd)
230  const wt = await prepareWorktree(host, {
231    repoRoot: root,
232    branch: state.branch ?? `auto/${state.ticketId}`,
233  })
234  return { ...state, worktreePath: wt.path, branch: wt.branch }
235}
236
237/** Per-check cap on carried-forward eval output; the tail is kept, since errors sit at the end of a log. */
238export const FAILURE_DETAIL_CHARS_PER_CHECK = 1500
239/** Cap on the carried-forward reviewer critique; the head is kept, since the verdict comes first. */
240export const REVIEW_FEEDBACK_CHARS = 2000
241
242function keepTail(text: string, max: number): string {
243  return text.length <= max ? text : `…(${text.length - max} earlier chars omitted)\n${text.slice(-max)}`
244}
245
246function keepHead(text: string, max: number): string {
247  return text.length <= max ? text : `${text.slice(0, max)}\n…(${text.length - max} more chars omitted)`
248}
249
250/** The failed checks' output, one block per check, for the next attempt's prompt. */
251export function failureDetail(report: Pick<EvalReport, 'failed'>): string | undefined {
252  if (report.failed.length === 0) return undefined
253  return report.failed
254    .map((c) => `--- ${c.name} (exit ${c.exitCode}) ---\n${keepTail(c.output.trim(), FAILURE_DETAIL_CHARS_PER_CHECK)}`)
255    .join('\n\n')
256}
257
258export function reviewFeedback(answer: string): string | undefined {
259  const trimmed = answer.trim()
260  return trimmed === '' ? undefined : keepHead(trimmed, REVIEW_FEEDBACK_CHARS)
261}
262
263/** The ticket (title and description) comes first, then any retry context from earlier attempts. */
264export function buildCodingPrompt(state: WorkflowState, ticket?: Pick<Ticket, 'title' | 'description'>): string {
265  const lines = [
266    `Ticket: ${state.ticketId}`,
267    `Attempt: ${state.attempt} of ${state.maxAttempts}`,
268  ]
269  if (ticket !== undefined) {
270    lines.push(`Title: ${ticket.title}`, '', ticket.description, '')
271  }
272  lines.push('Implement the ticket in the current worktree.')
273  if (state.humanAnswer !== undefined) {
274    lines.push(`Human answered a previous blocking question: ${state.humanAnswer}`)
275  }
276  if (state.blockerReason !== undefined) {
277    lines.push(`Previous blocker/failure reason: ${state.blockerReason}`)
278  }
279  if (state.lastEvalSummary !== undefined) {
280    lines.push(`Previous evaluation summary: ${state.lastEvalSummary}`)
281  }
282  if (state.lastFailureDetail !== undefined) {
283    lines.push('', 'Output of the checks that failed on the previous attempt:', state.lastFailureDetail)
284  }
285  if (state.lastReviewFeedback !== undefined) {
286    lines.push('', 'The reviewer rejected the previous attempt. Their feedback:', state.lastReviewFeedback)
287  }
288  return lines.join('\n')
289}
290
291export function buildReviewPrompt(state: WorkflowState, ticket?: Pick<Ticket, 'title' | 'description'>): string {
292  const lines = [`Ticket: ${state.ticketId}`]
293  if (ticket !== undefined) {
294    lines.push(`Title: ${ticket.title}`, '', ticket.description, '')
295  }
296  lines.push(
297    'Review the changes in the current worktree against the ticket above.',
298    'Respond with PASS or FAIL, followed by your reasons -- on FAIL, say concretely what must change.',
299  )
300  if (state.lastEvalSummary !== undefined) {
301    lines.push(`Evaluation summary: ${state.lastEvalSummary}`)
302  }
303  return lines.join('\n')
304}
305
306// ---------------------------------------------------------------------------
307// driveOneTicket -- drives one ticket from claim to a terminal status. Each
308// step reads its inputs from `state` and writes its result back into it.
309// ---------------------------------------------------------------------------
310
311async function driveOneTicket(
312  host: Host,
313  options: WorkflowOptions,
314  initialState: WorkflowState,
315): Promise<WorkflowState> {
316  let state = initialState
317  while (!isTerminal(state.status)) {
318    state = await drainExternal(host, state)
319    if (state.stopped) break
320    if (state.paused) {
321      await host.sleep(500)
322      continue
323    }
324
325    // These steps all touch the worktree on disk, so make sure it exists first.
326    if (
327      state.status === 'coding' ||
328      state.status === 'evaluating' ||
329      state.status === 'reviewing' ||
330      state.status === 'awaiting_approval'
331    ) {
332      state = await ensureWorktree(host, state, options)
333    }
334
335    switch (state.status) {
336      case 'claimed': {
337        const ticket = await getTicket(host, state.ticketId)
338        const root = await repoRoot(host, options.cwd)
339        const wt = await prepareWorktree(host, {
340          repoRoot: root,
341          branch: ticket?.branch ?? state.branch ?? `auto/${state.ticketId}`,
342        })
343        state = await persist(
344          host,
345          applyTransition(
346            { ...state, worktreePath: wt.path, branch: wt.branch },
347            'PREPARED',
348            await host.now(),
349          ),
350        )
351        break
352      }
353      case 'coding': {
354        const result = await runAgent(host, {
355          kind: 'coding',
356          state,
357          prompt: buildCodingPrompt(state, await getTicket(host, state.ticketId)),
358          description: `Code ${state.ticketId} attempt ${state.attempt}`,
359        })
360        // Stop aborts the agent; apply the queued STOP before the abort is misread as a coding failure.
361        if (result.isAborted) {
362          state = await drainExternal(host, state)
363          if (state.stopped) break
364        }
365        const withAgent = {
366          ...state,
367          lastModel: result.model,
368          ...(result.agentId !== undefined ? { agentId: result.agentId } : {}),
369        }
370        // Only a completed answer counts as finished; a refusal, error, abort, or denied spawn is a blocker.
371        const codingBlockerReason =
372          result.denied ??
373          (result.isAborted || result.reason !== 'answer'
374            ? `coding agent did not complete (reason: ${result.reason ?? 'aborted'}): ${
375                result.answer || 'no answer produced'
376              }`
377            : undefined)
378        state = codingBlockerReason !== undefined
379          ? await persist(
380              host,
381              applyTransition(
382                {
383                  ...withAgent,
384                  blockerReason: codingBlockerReason,
385                  // Set explicitly so a leftover question from an earlier blocker isn't re-asked.
386                  humanQuestion: `The coding agent could not finish: ${codingBlockerReason}. How should it proceed?`,
387                },
388                'BLOCKER_FOUND',
389                await host.now(),
390              ),
391            )
392          : await persist(
393              host,
394              applyTransition(
395                // Retry context has been shown once; clear it so it can't leak into a later attempt.
396                { ...withAgent, humanAnswer: undefined, lastFailureDetail: undefined, lastReviewFeedback: undefined },
397                'AGENT_FINISHED',
398                await host.now(),
399              ),
400            )
401        break
402      }
403      case 'evaluating': {
404        const worktreePath = state.worktreePath
405        if (worktreePath === undefined) {
406          throw new Error('autonomous-loop: evaluating with no worktreePath')
407        }
408        const report = await evaluateAttempt(host, { worktreePath })
409        if (report.outcome === 'pass') {
410          state = await persist(
411            host,
412            applyTransition(
413              { ...state, lastEvalSummary: report.summary },
414              'PASS',
415              await host.now(),
416            ),
417          )
418        } else {
419          const classification = classifyFailure({
420            report,
421            attempt: state.attempt,
422            maxAttempts: state.maxAttempts,
423          })
424          const event = toWorkflowEvent(classification, state.attempt, state.maxAttempts)
425          state = await persist(
426            host,
427            applyTransition(
428              {
429                ...state,
430                lastEvalSummary: report.summary,
431                lastFailureDetail: failureDetail(report),
432                blockerReason: classification.reason,
433                ...(classification.question !== undefined
434                  ? { humanQuestion: classification.question }
435                  : {}),
436              },
437              event,
438              await host.now(),
439            ),
440          )
441        }
442        break
443      }
444      case 'waiting_for_human': {
445        const answer = await askHuman(host, {
446          ticketId: state.ticketId,
447          question: state.humanQuestion ?? 'Need input to continue.',
448        })
449        state =
450          answer.answer === ''
451            ? await persist(host, applyTransition(state, 'CANCELLED', await host.now()))
452            : await persist(
453                host,
454                applyTransition(
455                  // Clear the answered question so a later blocker can't re-ask it.
456                  { ...state, humanAnswer: answer.answer, humanQuestion: undefined },
457                  'ANSWERED',
458                  await host.now(),
459                ),
460              )
461        break
462      }
463      case 'reviewing': {
464        const result = await runAgent(host, {
465          kind: 'review',
466          state,
467          prompt: buildReviewPrompt(state, await getTicket(host, state.ticketId)),
468          description: `Review ${state.ticketId}`,
469        })
470        if (result.isAborted) {
471          state = await drainExternal(host, state) // same STOP-vs-failure ordering as 'coding'
472          if (state.stopped) break
473        }
474        // A review that never completed has no verdict: treat it as FAIL, but record why.
475        const reviewBlockerReason =
476          result.denied ??
477          (result.isAborted || result.reason !== 'answer'
478            ? `review agent did not complete (reason: ${result.reason ?? 'aborted'}): ${
479                result.answer || 'no answer produced'
480              }`
481            : undefined)
482        const passVerdict =
483          reviewBlockerReason === undefined &&
484          /\bpass\b/i.test(result.answer) &&
485          !/\bfail\b/i.test(result.answer)
486        // Cap review failures at maxAttempts, as evaluation does, so the cycle can't run forever.
487        const reviewEvent = passVerdict
488          ? 'PASS'
489          : state.attempt >= state.maxAttempts
490            ? 'EXHAUSTED'
491            : 'FAIL'
492        state = await persist(
493          host,
494          applyTransition(
495            {
496              ...state,
497              lastModel: result.model,
498              lastReviewFeedback: passVerdict ? undefined : reviewFeedback(result.answer),
499              ...(reviewBlockerReason !== undefined ? { blockerReason: reviewBlockerReason } : {}),
500            },
501            reviewEvent,
502            await host.now(),
503          ),
504        )
505        break
506      }
507      case 'awaiting_approval': {
508        const diff = state.worktreePath !== undefined ? await diffStat(host, state.worktreePath) : undefined
509        const answer = await requestApproval(host, {
510          ticketId: state.ticketId,
511          summary: state.lastEvalSummary ?? '',
512          ...(diff !== undefined
513            ? { diffStat: `${diff.filesChanged} files, +${diff.insertions}/-${diff.deletions}` }
514            : {}),
515        })
516        state = await persist(
517          host,
518          applyTransition(
519            state,
520            answer.decision === 'approved' ? 'APPROVED' : 'REJECTED',
521            await host.now(),
522          ),
523        )
524        break
525      }
526      default:
527        break
528    }
529  }
530
531  if (state.stopped && !isTerminal(state.status)) {
532    // STOP is a kill switch, not a graph edge. Force a terminal status so a
533    // later resume doesn't pick the ticket up again -- the one deliberate
534    // exception to applyTransition owning every status change.
535    state = await persist(host, {
536      ...state,
537      status: 'blocked',
538      stopped: false,
539      updatedAtMs: await host.now(),
540    })
541  }
542
543  await updateTicket(host, state.ticketId, {
544    status: state.status === 'done' ? 'done' : 'blocked',
545  }).catch(() => undefined)
546
547  return state
548}
549
550// ---------------------------------------------------------------------------
551// runWorkflow -- the pool scheduler. Resumes unfinished runs, then keeps up
552// to `maxConcurrent` tickets active, refilling a slot as soon as one
553// finishes, until nothing is left to claim.
554// ---------------------------------------------------------------------------
555
556export async function runWorkflow(
557  host: Host,
558  options: WorkflowOptions,
559): Promise<void> {
560  if (running) {
561    throw new Error('autonomous-loop: workflow already running')
562  }
563  running = true
564  try {
565    const maxConcurrent = options.ticketId !== undefined ? 1 : (options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT)
566
567    const loaded = options.resume !== false ? await loadRuns(host) : new Map<string, WorkflowState>()
568    // A single-ticket run resumes only its own saved run.
569    const relevantLoaded = options.ticketId !== undefined
570      ? new Map([...loaded].filter(([ticketId]) => ticketId === options.ticketId))
571      : loaded
572
573    // Finished runs aren't resumable; drop them from the snapshot.
574    let archived = false
575    for (const [ticketId, run] of relevantLoaded) {
576      if (isTerminal(run.status)) {
577        archived = true
578        continue
579      }
580      current.set(ticketId, run)
581    }
582    if (archived) await saveRuns(host, current)
583
584    const active = new Map<string, Promise<void>>()
585    const launch = (state: WorkflowState): void => {
586      const ticketId = state.ticketId
587      active.set(
588        ticketId,
589        driveOneTicket(host, options, state).then(() => {
590          active.delete(ticketId)
591        }),
592      )
593    }
594    for (const state of current.values()) launch(state)
595
596    if (options.ticketId !== undefined) {
597      // Claim at most once and never refill: claiming by id skips the
598      // open-status check, so the refill loop would re-claim this same
599      // finished ticket forever.
600      if (active.size === 0) {
601        const fresh = await startFresh(host, options).catch(() => undefined)
602        if (fresh !== undefined) {
603          current.set(fresh.ticketId, fresh)
604          await saveRuns(host, current)
605          launch(fresh)
606        }
607      }
608      if (active.size > 0) await Promise.race(active.values())
609      return
610    }
611
612    for (;;) {
613      while (active.size < maxConcurrent) {
614        const fresh = await startFresh(host, options).catch(() => undefined)
615        if (fresh === undefined) break // nothing claimable right now
616        current.set(fresh.ticketId, fresh)
617        await saveRuns(host, current)
618        launch(fresh)
619      }
620      if (active.size === 0) break // nothing running and nothing claimable
621      await Promise.race(active.values())
622    }
623  } finally {
624    running = false
625  }
626}
627
628// ---------------------------------------------------------------------------
629// Readers, subscribers, and teardown.
630// ---------------------------------------------------------------------------
631
632/** Every run the pool holds this session, in claim order. */
633export function currentStates(): readonly WorkflowState[] {
634  return [...current.values()]
635}
636
637export function isRunning(): boolean {
638  return running
639}
640
641export function subscribe(listener: (state: WorkflowState) => void): () => void {
642  listeners.add(listener)
643  return () => {
644    listeners.delete(listener)
645  }
646}
647
648export function reset(): void {
649  current = new Map()
650  running = false
651  listeners = new Set()
652  pendingExternal = []
653  lastLoggedStatusByTicket = new Map()
654}
655
hooks/approval.ts 166 lines
1// hooks/approval.ts — the human gates: approve/reject a finished ticket, and
2// answer a blocking question. Each can be settled from a dialog or the dashboard.
3
4import type { Host } from './host'
5
6export type ApprovalDecision = 'approved' | 'rejected'
7
8export type ApprovalRequest = {
9  ticketId: string
10  summary: string
11  diffStat?: string
12}
13
14export type ApprovalAnswer = {
15  decision: ApprovalDecision
16  via: 'dialog' | 'dashboard'
17  answeredAtMs: number
18  note?: string
19}
20
21export type HumanQuestion = {
22  ticketId: string
23  question: string
24  options?: readonly string[]
25}
26
27export type HumanAnswer = {
28  answer: string
29  via: 'dialog' | 'dashboard'
30  answeredAtMs: number
31}
32
33export const APPROVE_LABEL = 'Approve'
34export const REJECT_LABEL = 'Reject'
35
36// ---------------------------------------------------------------------------
37// Approval requests, keyed by ticketId: several tickets can await approval at
38// once, and one ticket's Approve/Reject must never resolve another's.
39// ---------------------------------------------------------------------------
40
41const approvalReqs = new Map<string, ApprovalRequest>()
42const approvalResolves = new Map<string, (answer: ApprovalAnswer) => void>()
43
44export function requestApproval(
45  host: Host,
46  request: ApprovalRequest,
47): Promise<ApprovalAnswer> {
48  return new Promise<ApprovalAnswer>((resolve) => {
49    const ticketId = request.ticketId
50    approvalReqs.set(ticketId, request)
51    approvalResolves.set(ticketId, (answer: ApprovalAnswer) => {
52      approvalReqs.delete(ticketId)
53      approvalResolves.delete(ticketId)
54      resolve(answer)
55    })
56    host
57      .uiAsk(`Approve "${request.summary}"?`, [APPROVE_LABEL, REJECT_LABEL])
58      .then(async (label) => {
59        const answeredAtMs = await host.now()
60        approvalResolves.get(ticketId)?.({
61          decision: label === APPROVE_LABEL ? 'approved' : 'rejected',
62          via: 'dialog',
63          answeredAtMs,
64        })
65      })
66      .catch(() => {
67        // Dismissed or a -p run: no one to ask. Leave the dashboard-driven
68        // deferred pending; do NOT reject the returned promise.
69      })
70  })
71}
72
73export function resolveApproval(ticketId: string, decision: ApprovalDecision, note?: string): boolean {
74  const resolve = approvalResolves.get(ticketId)
75  if (!resolve) return false
76  resolve({
77    decision,
78    via: 'dashboard',
79    // Called synchronously from a Button onPress, where no engine clock is reachable.
80    answeredAtMs: Date.now(),
81    ...(note !== undefined ? { note } : {}),
82  })
83  return true
84}
85
86export function isAwaitingApproval(ticketId: string): boolean {
87  return approvalReqs.has(ticketId)
88}
89
90export function pendingApproval(ticketId: string): ApprovalRequest | undefined {
91  return approvalReqs.get(ticketId)
92}
93
94// ---------------------------------------------------------------------------
95// Blocking questions (free-text answer), keyed by ticketId for the same reason.
96// ---------------------------------------------------------------------------
97
98const humanQs = new Map<string, HumanQuestion>()
99const humanResolves = new Map<string, (answer: HumanAnswer) => void>()
100
101export function askHuman(host: Host, question: HumanQuestion): Promise<HumanAnswer> {
102  return new Promise<HumanAnswer>((resolve) => {
103    const ticketId = question.ticketId
104    humanQs.set(ticketId, question)
105    humanResolves.set(ticketId, (answer: HumanAnswer) => {
106      humanQs.delete(ticketId)
107      humanResolves.delete(ticketId)
108      resolve(answer)
109    })
110    host
111      .uiAsk(question.question, question.options)
112      .then(async (answer) => {
113        const answeredAtMs = await host.now()
114        humanResolves.get(ticketId)?.({
115          answer,
116          via: 'dialog',
117          answeredAtMs,
118        })
119      })
120      .catch(() => {
121        // Dismissed or a -p run: no one to ask. Leave the dashboard-driven
122        // deferred pending; do NOT reject the returned promise.
123      })
124  })
125}
126
127export function resolveHumanAnswer(ticketId: string, answer: string): boolean {
128  const resolve = humanResolves.get(ticketId)
129  if (!resolve) return false
130  resolve({ answer, via: 'dashboard', answeredAtMs: Date.now() })
131  return true
132}
133
134export function isAwaitingHuman(ticketId: string): boolean {
135  return humanQs.has(ticketId)
136}
137
138export function pendingQuestion(ticketId: string): HumanQuestion | undefined {
139  return humanQs.get(ticketId)
140}
141
142// ---------------------------------------------------------------------------
143// Teardown
144// ---------------------------------------------------------------------------
145
146/**
147 * Settles one ticket's pending request(s) on that ticket's Stop, leaving every
148 * other ticket untouched. A cancelled question settles with answer '', which
149 * workflow.ts treats as CANCELLED.
150 */
151export function cancelForTicket(ticketId: string, reason: string): void {
152  approvalResolves.get(ticketId)?.({ decision: 'rejected', via: 'dashboard', answeredAtMs: Date.now(), note: reason })
153  humanResolves.get(ticketId)?.({ answer: '', via: 'dashboard', answeredAtMs: Date.now() })
154  approvalReqs.delete(ticketId)
155  approvalResolves.delete(ticketId)
156  humanQs.delete(ticketId)
157  humanResolves.delete(ticketId)
158}
159
160/** Settles every ticket's pending request(s), on full teardown. */
161export function cancelAll(reason: string): void {
162  for (const ticketId of new Set([...approvalReqs.keys(), ...humanQs.keys()])) {
163    cancelForTicket(ticketId, reason)
164  }
165}
166
hooks/dashboard/render.tsx 175 lines
1// hooks/dashboard/render.tsx — draws the dashboard pane: the LOOP banner and
2// one card per active ticket, with its buttons.
3
4import type { EngineInterface, Frozen, RenderElement, RenderInput } from 'claude-code'
5import type { WorkflowState } from '../state'
6import type { ExternalEvent } from '../workflow'
7import * as animation from '../animation'
8
9/** The id this plugin opens its Pane with via `$.ui.open({ id: PANE_ID })`. */
10export const PANE_ID = 'autonomous-loop'
11export const PANE_TITLE = 'Autonomous Loop'
12
13export type DashboardProps = {
14  /** Every active run, in claim order. */
15  states: readonly WorkflowState[]
16  emit: (event: ExternalEvent) => void
17  awaitingApproval: (ticketId: string) => boolean
18  awaitingHuman: (ticketId: string) => boolean
19}
20
21/** True when this render event is for this plugin's own pane, not another plugin's. */
22export function isOwnPane(e: Frozen<RenderInput<'Pane'>>): boolean {
23  return e.component === 'Pane' && e.requestId === PANE_ID
24}
25
26// Below this pane width, buttons stack vertically instead of side by side.
27const NARROW_COLUMNS = 48
28
29/** One dim `label: value` line, truncated rather than wrapped so long values keep the layout intact. */
30function FieldLine(
31  Text: ReturnType<EngineInterface['ui']['resolve']>['Text'],
32  label: string,
33  value: string,
34): RenderElement {
35  return (
36    <Text dimColor wrap="truncate-end">
37      {label}: {value}
38    </Text>
39  )
40}
41
42/**
43 * One ticket's card: fields, blocker or question, buttons, and history.
44 * Every button emits an event carrying this card's own ticketId; the
45 * dashboard never changes WorkflowState directly.
46 */
47function RunCard(
48  elements: ReturnType<EngineInterface['ui']['resolve']>,
49  state: WorkflowState,
50  isNarrow: boolean,
51  props: DashboardProps,
52): RenderElement {
53  const { Box, Text, Button } = elements
54  const Input = 'Input' in elements ? elements.Input : undefined
55  const awaitingApproval = props.awaitingApproval(state.ticketId)
56  const awaitingHuman = props.awaitingHuman(state.ticketId)
57
58  return (
59    <Box flexDirection="column" marginTop={1} paddingX={1}>
60      <Text bold>
61        #{state.ticketId} — {statusLine(state)}
62      </Text>
63
64      <Box flexDirection="column">
65        {state.branch && FieldLine(Text, 'branch', state.branch)}
66        {state.lastModel && FieldLine(Text, 'model', state.lastModel)}
67        {state.agentId && FieldLine(Text, 'agent', state.agentId)}
68        {state.lastEvalSummary && FieldLine(Text, 'eval', state.lastEvalSummary)}
69      </Box>
70
71      {state.blockerReason && (
72        <Box marginTop={1}>
73          <Text wrap="wrap">blocker: {state.blockerReason}</Text>
74        </Box>
75      )}
76      {awaitingHuman && state.humanQuestion && (
77        <Box marginTop={1}>
78          <Text wrap="wrap">question: {state.humanQuestion}</Text>
79        </Box>
80      )}
81
82      <Box
83        flexDirection={isNarrow ? 'column' : 'row'}
84        gap={isNarrow ? 0 : 1}
85        marginTop={1}
86      >
87        <Button
88          label={state.paused ? 'Resume' : 'Pause'}
89          onPress={() => props.emit({ kind: state.paused ? 'RESUME' : 'PAUSE', ticketId: state.ticketId })}
90        />
91        <Button label="Stop" onPress={() => props.emit({ kind: 'STOP', ticketId: state.ticketId })} />
92        {awaitingApproval && (
93          <Button label="Approve" onPress={() => props.emit({ kind: 'APPROVED', ticketId: state.ticketId })} />
94        )}
95        {awaitingApproval && (
96          <Button label="Reject" onPress={() => props.emit({ kind: 'REJECTED', ticketId: state.ticketId })} />
97        )}
98      </Box>
99
100      {awaitingHuman && Input && (
101        <Box marginTop={1}>
102          <Input
103            key={`answer-${state.ticketId}`}
104            label="Answer"
105            placeholder="Type your answer and press Enter"
106            submitLabel="answer"
107            onSubmit={(value: string) => props.emit({ kind: 'HUMAN_ANSWERED', ticketId: state.ticketId, answer: value })}
108          />
109        </Box>
110      )}
111
112      <Box flexDirection="column" marginTop={1}>
113        <Text bold dimColor>history</Text>
114        {historyLines(state).map((line, i) => (
115          <Text key={String(i)} dimColor wrap="truncate-end">
116            {line}
117          </Text>
118        ))}
119      </Box>
120    </Box>
121  )
122}
123
124/**
125 * The pane body: title, LOOP banner, summary, then one card per active ticket.
126 *
127 * `elements` comes from `$.ui.resolve(e)` in index.ts rather than an import,
128 * so the same JSX works on every surface. Every Box sets `flexDirection`
129 * explicitly because the layout engine defaults to `row`.
130 */
131export function renderDashboard(
132  elements: ReturnType<EngineInterface['ui']['resolve']>,
133  e: Frozen<RenderInput<'Pane'>>,
134  props: DashboardProps,
135): RenderElement {
136  const { Box, Text } = elements
137  const isNarrow = e.props.bodyColumns < NARROW_COLUMNS
138
139  return (
140    <Box flexDirection="column" paddingX={1} width={e.props.bodyColumns}>
141      <Text bold>{PANE_TITLE}</Text>
142      <Box flexDirection="column">
143        {animation.currentFrame().map((line, i) => (
144          <Text key={String(i)} color="#CC785C" wrap="truncate">
145            {line}
146          </Text>
147        ))}
148      </Box>
149      <Text dimColor>{summaryLine(props.states)}</Text>
150
151      {props.states.map((state) => (
152        <Box key={state.ticketId} flexDirection="column">
153          {RunCard(elements, state, isNarrow, props)}
154        </Box>
155      ))}
156    </Box>
157  )
158}
159
160export function summaryLine(states: readonly WorkflowState[]): string {
161  if (states.length === 0) return 'no active runs'
162  return `${states.length} active run${states.length === 1 ? '' : 's'}`
163}
164
165export function statusLine(state: WorkflowState): string {
166  return `${state.status} (attempt ${state.attempt}/${state.maxAttempts})`
167}
168
169/** The last `limit` transitions, oldest first. */
170export function historyLines(state: WorkflowState, limit = 10): readonly string[] {
171  return state.history
172    .slice(-limit)
173    .map((h) => `${h.from} --${h.event}--> ${h.to}`)
174}
175
hooks/host.ts 35 lines
1// hooks/host.ts — the engine capabilities the loop uses, as a plain object.
2//
3// Claude Code statically checks a hooks module before loading it, and refuses
4// one that uses `$` as a value: assigning it, passing it to a function,
5// spreading or returning it. `$` may only appear as a literal
6// `$.noun.method(...)` call. That lets the engine list exactly which
7// capabilities a plugin uses.
8//
9// So index.ts builds a `Host` inline in session.start: an object of small
10// closures, each a literal `$` call (`now: () => $.clock.now()`). The object
11// is not `$`, so it can be passed into workflow.ts and everything it calls,
12// and tests can pass a fake one instead.
13
14import type { EngineInterface } from 'claude-code'
15
16export type Host = {
17  now: EngineInterface['clock']['now']
18  sleep: EngineInterface['clock']['sleep']
19  every: EngineInterface['clock']['every']
20  storeGet: EngineInterface['store']['get']
21  storeSet: EngineInterface['store']['set']
22  storeDelete: EngineInterface['store']['delete']
23  run: EngineInterface['process']['run']
24  fsExists: EngineInterface['fs']['exists']
25  spawnAgent: EngineInterface['agent']['spawn']
26  listAgents: EngineInterface['agent']['list']
27  uiAsk: EngineInterface['ui']['ask']
28  uiOpen: EngineInterface['ui']['open']
29  uiInvalidate: EngineInterface['ui']['invalidate']
30  uiLog: EngineInterface['ui']['log']
31}
32
33// There is deliberately no `hostOf($)` builder: calling it would pass `$` as
34// an argument, the very thing the engine refuses. Only the type lives here.
35
hooks/state.ts 159 lines
1// hooks/state.ts -- the workflow's types, its transition table (the graph),
2// model routing, and persistence of per-ticket run state.
3
4import type { Host } from './host'
5
6export type WorkflowStatus =
7  | 'claimed'
8  | 'coding'
9  | 'evaluating'
10  | 'waiting_for_human'
11  | 'reviewing'
12  | 'awaiting_approval'
13  | 'done'
14  | 'blocked'
15
16export type WorkflowEvent =
17  | 'PREPARED'
18  | 'AGENT_FINISHED'
19  | 'BLOCKER_FOUND'
20  | 'PASS'
21  | 'RETRY'
22  | 'EXHAUSTED'
23  | 'ANSWERED'
24  | 'CANCELLED'
25  | 'FAIL'
26  | 'APPROVED'
27  | 'REJECTED'
28
29export type ModelTier = 'haiku' | 'sonnet' | 'opus'
30
31export type TransitionTable = Readonly<
32  Record<WorkflowStatus, Readonly<Partial<Record<WorkflowEvent, WorkflowStatus>>>>
33>
34
35export type HistoryEntry = {
36  from: WorkflowStatus
37  event: WorkflowEvent
38  to: WorkflowStatus
39  attempt: number
40  atMs: number
41  note?: string
42}
43
44export type WorkflowState = {
45  ticketId: string
46  status: WorkflowStatus
47  attempt: number
48  maxAttempts: number
49  worktreePath?: string
50  branch?: string
51  agentId?: string
52  lastModel?: ModelTier
53  lastEvalSummary?: string
54  /** Failed checks' output (tail, capped) from the last evaluation; cleared after the next coding attempt. */
55  lastFailureDetail?: string
56  /** The reviewer's critique (capped) from a review that did not pass; cleared after the next coding attempt. */
57  lastReviewFeedback?: string
58  blockerReason?: string
59  humanQuestion?: string
60  humanAnswer?: string
61  approval?: 'pending' | 'approved' | 'rejected'
62  paused: boolean
63  stopped: boolean
64  history: HistoryEntry[]
65  leaseExpiresAtMs: number
66  createdAtMs: number
67  updatedAtMs: number
68}
69
70export type ModelRoutingState = Pick<WorkflowState, 'status' | 'attempt'>
71
72export const RUNS_STORE_KEY = 'autonomous-loop/runs'
73export const DEFAULT_MAX_ATTEMPTS = 3
74export const DEFAULT_LEASE_MS = 120_000
75export const TERMINAL_STATUSES: readonly WorkflowStatus[] = ['done', 'blocked']
76
77export const transitions: TransitionTable = {
78  claimed: { PREPARED: 'coding' },
79  coding: { AGENT_FINISHED: 'evaluating', BLOCKER_FOUND: 'waiting_for_human' },
80  evaluating: {
81    PASS: 'reviewing',
82    RETRY: 'coding',
83    BLOCKER_FOUND: 'waiting_for_human',
84    EXHAUSTED: 'blocked',
85  },
86  waiting_for_human: { ANSWERED: 'coding', CANCELLED: 'blocked' },
87  // EXHAUSTED caps the reviewing -> coding -> evaluating -> reviewing cycle at maxAttempts.
88  reviewing: { PASS: 'awaiting_approval', FAIL: 'coding', EXHAUSTED: 'blocked' },
89  awaiting_approval: { APPROVED: 'done', REJECTED: 'blocked' },
90  done: {},
91  blocked: {},
92}
93
94export function createInitialState(args: {
95  ticketId: string
96  nowMs: number
97  maxAttempts?: number
98  branch?: string
99}): WorkflowState {
100  return {
101    ticketId: args.ticketId,
102    status: 'claimed',
103    attempt: 0,
104    maxAttempts: args.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
105    paused: false,
106    stopped: false,
107    history: [],
108    leaseExpiresAtMs: args.nowMs + DEFAULT_LEASE_MS,
109    createdAtMs: args.nowMs,
110    updatedAtMs: args.nowMs,
111    ...(args.branch !== undefined ? { branch: args.branch } : {}),
112  }
113}
114
115export function nextStatus(
116  status: WorkflowStatus,
117  event: WorkflowEvent,
118): WorkflowStatus | undefined {
119  return transitions[status][event]
120}
121
122export function isTerminal(status: WorkflowStatus): boolean {
123  return TERMINAL_STATUSES.includes(status)
124}
125
126/**
127 * Model routing: reviews always use opus; the first coding attempt uses
128 * sonnet, and a retry escalates to opus, since a failure suggests a harder task.
129 */
130export function pickModel(state: ModelRoutingState): ModelTier {
131  if (state.status === 'reviewing') return 'opus'
132  return state.attempt > 1 ? 'opus' : 'sonnet'
133}
134
135/**
136 * Reads every persisted run, keyed by ticketId. A malformed entry is dropped
137 * rather than thrown, so one corrupt record never blocks the others' resume.
138 */
139export async function loadRuns(host: Host): Promise<Map<string, WorkflowState>> {
140  const raw = await host.storeGet(RUNS_STORE_KEY)
141  if (raw === undefined || raw === null || typeof raw !== 'object') return new Map()
142  const runs = new Map<string, WorkflowState>()
143  for (const [ticketId, value] of Object.entries(raw as Record<string, unknown>)) {
144    if (value !== null && typeof value === 'object' && 'status' in value) {
145      runs.set(ticketId, value as WorkflowState)
146    }
147  }
148  return runs
149}
150
151/**
152 * Writes the whole runs map as one snapshot. The store has no compare-and-set,
153 * so a read-modify-write would race between concurrent tickets; the caller
154 * keeps the authoritative map in memory and every save is a plain overwrite.
155 */
156export async function saveRuns(host: Host, runs: ReadonlyMap<string, WorkflowState>): Promise<void> {
157  await host.storeSet(RUNS_STORE_KEY, Object.fromEntries(runs))
158}
159
hooks/ticket.ts 184 lines
1// hooks/ticket.ts — the ticket queue: a demo list kept in the plugin's store,
2// with claims, leases, and updates serialized through one lock.
3
4import type { Host } from './host'
5
6export type TicketStatus = 'open' | 'claimed' | 'in_progress' | 'blocked' | 'done'
7
8export type Ticket = {
9  id: string
10  title: string
11  description: string
12  status: TicketStatus
13  branch: string
14  assignee?: string
15  claimedAtMs?: number
16  leaseExpiresAtMs?: number
17  updatedAtMs: number
18}
19
20export const TICKET_STORE_KEY = 'autonomous-loop/tickets'
21
22const DEFAULT_TICKET_LEASE_MS = 120_000
23
24export const DEMO_TICKETS: readonly Ticket[] = [
25  {
26    id: 'DEMO-1',
27    title: 'Add a health-check endpoint',
28    description: 'Expose GET /health returning { ok: true } for uptime checks.',
29    status: 'open',
30    branch: 'auto/demo-1-health-check',
31    updatedAtMs: 0,
32  },
33  // Small and self-contained: sized to run the full graph and pass first time.
34  {
35    id: 'DEMO-2',
36    title: 'Add a formatDuration utility',
37    description:
38      'Add a pure function `formatDuration(ms: number): string` to a new file `hooks/format-duration.ts`. ' +
39      'It formats a non-negative millisecond duration as compact `Xh Ym Zs`-style text, omitting any ' +
40      'leading zero unit (e.g. 1500 -> "1s", 61000 -> "1m 1s", 3661000 -> "1h 1m 1s"), and returns "0s" ' +
41      'for 0. Add `hooks/format-duration.test.ts` with real `node:test` cases covering at least: 0, under ' +
42      'a minute, exactly one minute, and over an hour. Do not modify any other file.',
43    status: 'open',
44    branch: 'auto/demo-2-format-duration',
45    updatedAtMs: 0,
46  },
47]
48
49// The store is one JSON list with only get/set, so every change is a
50// whole-list read-modify-write with awaits in between. Unserialized, two
51// concurrent writers (pool drivers, heartbeat renewals) could each read the
52// same list and the later write would drop the earlier change -- e.g. a lease
53// renewal reverting a fresh claim to `open`. Every public function below runs
54// under `withTicketLock`, a promise-chain mutex. It is not re-entrant, so
55// locked code calls only the `*Unlocked` helpers. It protects one process only.
56
57let lockTail: Promise<unknown> = Promise.resolve()
58
59/** Runs `fn` once every earlier locked call has settled; a failure never wedges the queue. */
60export function withTicketLock<T>(fn: () => Promise<T>): Promise<T> {
61  const run = lockTail.then(fn, fn)
62  lockTail = run.catch(() => undefined)
63  return run
64}
65
66async function listTicketsUnlocked(host: Host): Promise<Ticket[]> {
67  const stored = await host.storeGet(TICKET_STORE_KEY)
68  if (!Array.isArray(stored) || stored.length === 0) {
69    await host.storeSet(TICKET_STORE_KEY, DEMO_TICKETS)
70    return [...DEMO_TICKETS]
71  }
72  const storedList = stored as Ticket[]
73  // Append demo tickets added since the store was seeded; existing entries
74  // (and their live status/claim/lease) are never touched.
75  const knownIds = new Set(storedList.map((t) => t.id))
76  const missing = DEMO_TICKETS.filter((t) => !knownIds.has(t.id))
77  if (missing.length === 0) return storedList
78  const merged = [...storedList, ...missing]
79  await host.storeSet(TICKET_STORE_KEY, merged)
80  return merged
81}
82
83async function updateTicketUnlocked(
84  host: Host,
85  ticketId: string,
86  patch: Partial<Omit<Ticket, 'id'>>,
87): Promise<Ticket> {
88  const list = await listTicketsUnlocked(host)
89  const index = list.findIndex((t) => t.id === ticketId)
90  if (index === -1) {
91    throw new Error(`ticket.ts: unknown ticketId ${ticketId}`)
92  }
93  const existing = list[index] as Ticket
94  const updated: Ticket = {
95    ...existing,
96    ...patch,
97    updatedAtMs: await host.now(),
98  }
99  const next = [...list]
100  next[index] = updated
101  await host.storeSet(TICKET_STORE_KEY, next)
102  return updated
103}
104
105export function listTickets(host: Host): Promise<Ticket[]> {
106  return withTicketLock(() => listTicketsUnlocked(host))
107}
108
109export function seedTickets(
110  host: Host,
111  tickets: readonly Ticket[],
112): Promise<void> {
113  return withTicketLock(() => host.storeSet(TICKET_STORE_KEY, tickets))
114}
115
116export async function getTicket(
117  host: Host,
118  ticketId: string,
119): Promise<Ticket | undefined> {
120  return (await listTickets(host)).find((t) => t.id === ticketId)
121}
122
123export async function nextOpenTicket(host: Host): Promise<Ticket | undefined> {
124  return (await listTickets(host)).find((t) => t.status === 'open')
125}
126
127/** Finds and claims a ticket under one lock hold, so no other writer can slip in between. */
128export function claimTicket(
129  host: Host,
130  args?: { ticketId?: string; assignee?: string; leaseMs?: number },
131): Promise<Ticket | undefined> {
132  return withTicketLock(async () => {
133    const list = await listTicketsUnlocked(host)
134    const ticket = args?.ticketId !== undefined
135      ? list.find((t) => t.id === args.ticketId)
136      : list.find((t) => t.status === 'open')
137    if (ticket === undefined) return undefined
138
139    const leaseMs = args?.leaseMs ?? DEFAULT_TICKET_LEASE_MS
140    const nowMs = await host.now()
141
142    return updateTicketUnlocked(host, ticket.id, {
143      status: 'claimed',
144      claimedAtMs: nowMs,
145      leaseExpiresAtMs: nowMs + leaseMs,
146      ...(args?.assignee !== undefined ? { assignee: args.assignee } : {}),
147    })
148  })
149}
150
151export function updateTicket(
152  host: Host,
153  ticketId: string,
154  patch: Partial<Omit<Ticket, 'id'>>,
155): Promise<Ticket> {
156  return withTicketLock(() => updateTicketUnlocked(host, ticketId, patch))
157}
158
159export function renewLease(
160  host: Host,
161  ticketId: string,
162  leaseMs: number,
163): Promise<Ticket | undefined> {
164  return withTicketLock(async () => {
165    const ticket = (await listTicketsUnlocked(host)).find((t) => t.id === ticketId)
166    if (ticket === undefined) return undefined
167    const nowMs = await host.now()
168    return updateTicketUnlocked(host, ticketId, { leaseExpiresAtMs: nowMs + leaseMs })
169  })
170}
171
172export async function releaseTicket(host: Host, ticketId: string): Promise<void> {
173  try {
174    await updateTicket(host, ticketId, {
175      status: 'open',
176      assignee: undefined,
177      claimedAtMs: undefined,
178      leaseExpiresAtMs: undefined,
179    })
180  } catch {
181    // Unknown ticket: nothing to release.
182  }
183}
184
hooks/worktree.ts 155 lines
1// hooks/worktree.ts — thin wrapper over git: one worktree per ticket.
2// Every command runs as argv (no shell) with an explicit cwd.
3
4import type { Host } from './host'
5
6export type Worktree = {
7  path: string
8  branch: string
9  baseRef: string
10  createdAtMs: number
11}
12
13export type DiffStat = {
14  filesChanged: number
15  insertions: number
16  deletions: number
17}
18
19export type PrepareWorktreeArgs = {
20  repoRoot: string
21  branch: string
22  baseRef?: string // default 'HEAD'
23  root?: string // parent dir for worktrees; default `${repoRoot}/.worktrees`
24}
25
26export const WORKTREE_DIR_NAME = '.worktrees'
27
28export async function repoRoot(host: Host, cwd: string): Promise<string> {
29  const r = await host.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
30  if (r.exitCode !== 0) {
31    throw new Error(r.stderr || 'git rev-parse failed')
32  }
33  return r.stdout.trim()
34}
35
36export function worktreeExists(host: Host, path: string): Promise<boolean> {
37  return host.fsExists(path)
38}
39
40export async function listWorktrees(
41  host: Host,
42  repoRoot: string,
43): Promise<Worktree[]> {
44  const r = await host.run(['git', 'worktree', 'list', '--porcelain'], {
45    cwd: repoRoot,
46  })
47  if (r.exitCode !== 0) {
48    throw new Error(r.stderr || 'git worktree list failed')
49  }
50
51  // Porcelain output: blank-line-separated blocks of `worktree <path>` plus
52  // `branch refs/heads/<name>` or `detached`. It has no creation time, so
53  // createdAtMs is "observed now".
54  const worktrees: Worktree[] = []
55  const blocks = r.stdout.split(/\r?\n\r?\n/)
56  for (const block of blocks) {
57    const lines = block.split(/\r?\n/).filter((line) => line.length > 0)
58    if (lines.length === 0) continue
59    const worktreeLine = lines.find((line) => line.startsWith('worktree '))
60    if (worktreeLine === undefined) continue
61    const path = worktreeLine.slice('worktree '.length)
62    const branchLine = lines.find((line) => line.startsWith('branch '))
63    const branch =
64      branchLine !== undefined
65        ? branchLine.slice('branch '.length).replace(/^refs\/heads\//, '')
66        : 'detached'
67    worktrees.push({
68      path,
69      branch,
70      baseRef: '',
71      createdAtMs: await host.now(),
72    })
73  }
74  return worktrees
75}
76
77/**
78 * True when `branch` already exists as a local ref. `git worktree add -b`
79 * refuses an existing branch, which is what remains when a worktree
80 * directory was removed but its branch was not.
81 */
82async function branchExists(host: Host, args: { repoRoot: string; branch: string }): Promise<boolean> {
83  const r = await host.run(['git', 'rev-parse', '--verify', '--quiet', `refs/heads/${args.branch}`], {
84    cwd: args.repoRoot,
85  })
86  return r.exitCode === 0
87}
88
89export async function prepareWorktree(
90  host: Host,
91  args: PrepareWorktreeArgs,
92): Promise<Worktree> {
93  const baseRef = args.baseRef ?? 'HEAD'
94  const root = args.root ?? `${args.repoRoot}/${WORKTREE_DIR_NAME}`
95  const path = `${root}/${args.branch}`
96
97  if (await worktreeExists(host, path)) {
98    return { path, branch: args.branch, baseRef, createdAtMs: await host.now() }
99  }
100
101  const alreadyHasBranch = await branchExists(host, { repoRoot: args.repoRoot, branch: args.branch })
102  const argv = alreadyHasBranch
103    ? ['git', 'worktree', 'add', path, args.branch]
104    : ['git', 'worktree', 'add', '-b', args.branch, path, baseRef]
105  const r = await host.run(argv, { cwd: args.repoRoot })
106  if (r.exitCode !== 0) {
107    throw new Error(`git worktree add failed: ${r.stderr}`)
108  }
109
110  return { path, branch: args.branch, baseRef, createdAtMs: await host.now() }
111}
112
113export async function cleanupWorktree(
114  host: Host,
115  worktreePath: string,
116  options?: { removeBranch?: boolean; branch?: string },
117): Promise<void> {
118  try {
119    // `worktreePath` is about to be deleted, so run from its parent (the
120    // worktrees root), which `git worktree remove` leaves in place.
121    const parent = worktreePath.slice(0, worktreePath.lastIndexOf('/')) || '/'
122
123    await host
124      .run(['git', 'worktree', 'remove', '--force', worktreePath], { cwd: parent })
125      .catch(() => undefined)
126
127    if (options?.removeBranch && options.branch) {
128      await host
129        .run(['git', 'branch', '-D', options.branch], { cwd: parent })
130        .catch(() => undefined)
131    }
132  } catch {
133    // never throw
134  }
135}
136
137export async function diffStat(
138  host: Host,
139  worktreePath: string,
140): Promise<DiffStat> {
141  const r = await host.run(['git', 'diff', '--shortstat'], {
142    cwd: worktreePath,
143  })
144
145  const filesMatch = r.stdout.match(/(\d+) files? changed/)
146  const insertionsMatch = r.stdout.match(/(\d+) insertions?\(\+\)/)
147  const deletionsMatch = r.stdout.match(/(\d+) deletions?\(-\)/)
148
149  return {
150    filesChanged: filesMatch ? Number(filesMatch[1]) : 0,
151    insertions: insertionsMatch ? Number(insertionsMatch[1]) : 0,
152    deletions: deletionsMatch ? Number(deletionsMatch[1]) : 0,
153  }
154}
155