---
{
  "n": 10,
  "title": "The engine, the loop from A to Z",
  "abstract": "Supervisor, driver, agent and record: what each is, the one loop that runs them, and how an event reaches the agent \u2014 for approval before a line of engine code is written",
  "refs": [],
  "seen": [
    "agent",
    "user"
  ],
  "data": {},
  "created": 1789734495.0,
  "updated": 1789960847.747484,
  "deleted": 0.0,
  "completed": 0.0,
  "outcome": "",
  "type": "doc"
}
---
Four parts, four responsibilities, nothing shared between them but the record on disk.

    SUPERVISOR   a process. Keeps the driver alive. Nothing else.
    DRIVER       a process. Owns one agent. Delivers events to it, nudges it when idle, answers questions about it.
    AGENT        an interface. Claude, Codex — the same methods, implemented over a pty.
    RECORD       files per environment. Every resource is one kind of file; every user action is an event in a log.

The loop, A to Z:

    A  the user does something in the viewer (or the CLI): writes a message, deletes one, approves a plan,
       pauses one, flips auto, answers a question, reacts, comments
    B  the viewer writes the resource AND appends one line to the environment's event log:
       {id, at, kind, ref, by}  — kind is the verb (message.created, plan.approved, auto.enabled, …)
    C  the driver ticks (every second). It reads the event log from the last id it delivered.
    D  for every undelivered event, in order: driver.agent.send(render(event)) — typed into the agent
       NOW, idle or not; the agent's own input queue holds it. The event is marked delivered (the id is
       written in the driver's cursor file). Delivered is not handled: a message stays in the agent's
       list until the agent processes it, and the driver says so again at the next idle moment.
    E  the driver asks agent.is_idle(). If idle and nothing undelivered: it asks the record what is
       owed — open work, the next to-do under auto, a message still unprocessed — and sends the one
       line. This is the keep-going nudge, and it is the only thing that waits for idle.
    F  the driver writes its seat record: agent name, idle/working/waiting, last decision, last line printed.
    G  the agent works. Its hooks write one line per event (Stop, PreToolUse, PostToolUse, UserPromptSubmit,
       SessionStart, SessionEnd) to the agent's report file. The driver reads that file to answer is_idle,
       is_working, is_waiting. Hooks decide nothing else, except what must be refused inside the call
       (a write with no work open).
    H  the agent runs journal commands. Those are CRUD on resources: the same controller for every kind.
    I  a journal command that changes a resource appends to the event log too (agent-side events: work
       started, to-do closed) — the viewer reads the same log for its activity column.
    Z  the supervisor: if the driver process dies, start it again and hand it the agent's pty; the agent
       never notices. If the agent dies, the driver reports it and the supervisor stops.

Failure cases, each with its answer:
    the agent is mid-turn when an event arrives     → typed anyway; Claude queues it (measured today: Enter must come 300 ms after the text)
    the driver dies                                  → the supervisor restarts it; the cursor file says where delivery stopped, nothing is typed twice
    the driver's code changes                        → the supervisor restarts it (no hot reload inside a process)
    two drivers on one journal                       → each has its own agent's report file, named by the agent's session id
    the user is typing in the terminal               → the driver still types events (they go to the input queue), and never types the keep-going nudge
    an event the agent never handles                 → it is still in the record; the idle nudge names it every time until it is processed

What does NOT exist any more: told-once marks, the news module, the stop hook's queue for a seated
agent, per-kind push cases. One log, one cursor, one nudge.

## The pieces as code shapes
engine/
      supervisor.py   run(driver_argv): loop { p = spawn(driver); wait(p); if exit != clean: restart } — under 60 lines
      driver.py       Driver(agent, record): tick() = deliver_events(); nudge_if_idle(); write_seat()   — under 200 lines
      agent.py        Agent (abstract): start(), send(text), is_idle(), is_working(), is_waiting(), last_printed(), stop()
                      ClaudeAgent(Agent), CodexAgent(Agent): command line, hooks config path, report file — each under 60 lines
      record.py       Record(root, env): events(since_id), append(event), resources(kind), cursor read/write
      resource.py     Resource (abstract): kind, fields {title, brief, sections[], refs[], comments[]} — one file per resource
                      Controller (abstract): create, update, delete (soft), force_delete, reference, unreference, comment, show, list
                      Concrete controllers only name the kind: Messages, Todos, Plans, Docs, Pins, Rules, Reports, Questions …
      hooks.py        the one hook: report the event to the agent's report file; refuse a write with no open work

    The engine's logic — driver + supervisor + agent + record — stays under 1,000 lines. Resource kinds are data,
    not code: adding one is a name.

## Where it lives
v2/
      engine/        supervisor.py  driver.py  agent.py  record.py  resource.py  hooks.py
      commands/      the CLI verbs: one file per resource kind, each a name and nothing more
      controllers/   the one CRUD controller and the concrete kinds that name themselves

Nothing under v2/ imports anything outside v2/. The old tree keeps running beside it until v2 replaces it.

## The engine's methods, before its tests
Every resource lives in environments/<env>/<type>/NNN.md; everything is scoped to one environment. The actors are USER, AGENT and SYSTEM (the supervisor and the driver act as SYSTEM).

AGENT — the interface every agent implements (Claude, Codex); no logic of its own
    command(args) -> list[str]        the process to start
    send(text)                        type one line into the agent, Enter a beat later
    is_idle() -> bool                 last report Stop or SessionStart, and quiet for 1 s (no reports: quiet for 3 s)
    is_working() -> bool              not idle
    is_waiting() -> bool              a tool call under way and quiet for 5 s
    last_report() -> dict | None      the hooks' last line: {at, event, session, tool}
    last_printed() -> str             the tail of what it printed, plain text

DRIVER — one per agent, one environment
    tick() -> str                     deliver(); nudge(); seat(); returns what it decided, for the record
    deliver() -> str                  every USER event past the cursor, typed at once, idle or not; the cursor moves per event
    nudge() -> str                    only when the agent is idle and nothing was just typed: send(owed())
    owed() -> str                     the first of, in order: unseen resources by PRIORITY (messages first) as one
                                      line per type; open work; the next to-do when auto is on; else ""
    seat()                            writes runtime/seat-<session>.json: actor, idle, waiting, why, last printed
    run()                             tick every second, forever

SUPERVISOR — the process, and the only thing with a terminal
    run(root, cwd, env, agent, args)  start the agent in a pty; relay terminal <-> pty; keep the printed tail;
                                      spawn the driver with the pty fd; every 5 s: restart the driver if it died
                                      or any v2/*.py changed; put the agent's identity in its environment (actor AGENT)
    stop()                            end the driver, then the agent; put the terminal back

RECORD — the files
    emit(type, n, action, actor, **data) -> Event      the one funnel; also bus.emit(event)
    events(since) -> list[Event]
    cursor(name) / set_cursor(name, id)
    setting(key) / set_setting(key, value)              per environment (auto, …)

HOOK — one script, two duties
    report(event)                     one line to runtime/agents/<session>.jsonl
    gate(tool call)                   refuse a write while no work is open, from inside the call

The engine tests, once you approve these: (1) driver.tick with a fake agent — an event is typed at once
while working; the idle nudge names unseen resources by priority, then open work, then the next to-do;
the cursor never types twice; (2) record.emit reaches the bus; (3) the supervisor around a real Claude in
a pty: the typed line is sent, the driver survives a kill and a code change, the seat record fills.

## The frontend, from the manifest
A type declares how it is read: view = small | wide | document (VIEWS on the resource; small is the default inspector, wide a broad one, document a reading page) and nav = whether it sits in the sidebar. The backend serves one manifest — GET /api/manifest: every type's name, title, abstract, help, view, nav, names map, the shared actions, the priority — and the plain resource routes: GET /api/<env>/<type>, GET /api/<env>/<type>/<n>, POST /api/<env>/<type>/<n>/<action> and POST /api/<env>/<type> (create), where <action> may be the default word or the type's own (the controller's method() resolves both). Static files come from v2/web/dist.

The frontend is v2/web, Vue 3 single-file components with scoped styles, built by Vite, the old tokens copied into tokens.css, nothing shared with the old app.js. Components: ResourceGrid (the index page every type reuses: the same list, columns from the manifest), Inspector (small | wide, over the page), ResourcePage (the document view), ResourceActions (buttons from the type's actions and names), App (sidebar from nav types, routes #/<env>/<type>[/<n>]). One component per shape, none per type.

## Rules for the components
One component per shape, none per type. Scoped styles in every single-file component. No more than five levels of element nesting inside a component's template: a sixth level is another component. Nothing under v2/web imports the old static/ tree; the old tokens are copied once into tokens.css. Every request names type, n and action; the action may be the default word or the type's own.

## Features
A capability that fits in a few words — writing a plan, closing a to-do, repeating the reminders — is a feature: v2/features/<feature>/ with handlers.py (register() → bus.on), its trigger settings and its tests. The engine and the HTTP server load every feature at start; settings switch one off per environment. Handlers are the concrete edge: the one place a type or an event is named by hand. Everything beneath them — resources, controllers, record, bus, engine, drivers, providers — is generic. Hooks report and set state only; drivers only talk to the agent; nothing calls a feature directly, features chain by events.

## Triggers and nudges
A type declares its audience: `notify` names the actors told of its events besides the actor (the agent row tells nobody; a nudge tells the agent only), and `spoken` says the engine types the resource's own title and brief instead of "type n action". A feature that wants to tell the agent something creates a nudge as SYSTEM; the engine delivers it like any event. When it does is a trigger: `v2/features/trigger.py` measures a cadence in one unit — context percent crossed, tool uses, minutes, idle, start — from the agent row the hooks keep (status, event, uses, context), and remembers the last firing per session and feature under runtime/. Every trigger is a setting (`triggers.<feature>` = {every, unit} or {on}) with a default in the feature; `features.<name>` = false switches a feature off per environment. The reminders feature is the first: on idle, by default, it says the standing reminders.

## Since 2026-09-21, one process
This design has been replaced. There is no separate engine process. The server (serve.py) is the one long-lived process per project, and it runs one engine per live session in a thread (engine.engine.Engines). Beside Claude, engine/terminal.py holds the pty and runs the supervisor. The supervisor draws the band, keeps the viewer and plugin services up, and types whatever arrives on runtime/typist-<session>.sock. Every line to the agent passes channel.py: at most one message every five seconds, merged into counts.
