SLOPSHOPPER

agent-state-observer

Observe-only: writes the agent's state (working / idle / awaiting_approval / ended), its transitions and its rate-limit readings to a JSON file. Never blocks…

newguardtimer
★ 116v0.1.0MITupdated 2026-10-08Szotasz/marveen/plugins/agent-state-observer
A shopper browsing a rack in a slop shop
README

Marveen

Marveen Banner

Node.js TypeScript SQLite Claude Code Ollama Telegram Slack GitHub stars

AI csapatod, ami fut amíg te alszol.

Marveen egy AI asszisztens keretrendszer, ami Claude Code-ra épül. Saját AI csapatot építhetsz, akik Telegramon, Slacken vagy Discordon kommunikálnak veled, önállóan dolgoznak, és egymással is együttműködnek.

Marveen is a self-hostable agent harness for Claude Code: it runs a team of AI agents, each with its own chat channel (Telegram, Slack or Discord), persistent memory, scheduled tasks, and MCP tools, lets them delegate work to one another, and gives you a web dashboard to watch and steer them.

Funkciók

  • AI Csapat: Több ágens, mindegyik saját csatornával (Telegram, Slack vagy Discord), személyiséggel és memóriával
  • Mission Control: Web dashboard (http://localhost:3420) a csapat kezeléséhez
  • Inter-agent kommunikáció: Az ágensek delegálhatnak egymásnak feladatokat
  • Ütemezések: Cron-alapú feladatok automatikus futtatása
  • Kanban: Feladattábla AI auto-bontással; Jira-szerű nézetek — swimlane (csoportosítás felelős vagy prioritás szerint), oszloponkénti WIP-limit, beakadt-kártya jelzés (card-aging), alfeladat-beágyazás és kártya-szerkesztő
  • Heartbeat: Csendes háttér-monitorozás, csak fontosnál szól (naptár, email, kanban)
  • Memória: Hot/Warm/Cold tier rendszer, hibrid kereséssel (FTS5 + vektor) és gráf nézettel
  • MCP Connectorok: Gmail, Calendar, Drive, Notion, Slack és más szolgáltatások
  • Skillek: Újrahasználható képességek az ágenseknek
  • Öntanulás: Az ágensek automatikusan tanulnak a munkájukból és skill-eket hoznak létre

📚 Dokumentáció

Részletes, funkciónkénti leírások a docs/ mappában — mindegyik lap két szemszögből: 🎯 mit tud / miért érdekes + 🛠 hogyan működik.

FunkcióLap
Heartbeat + fokozatos autonómiadocs/heartbeat-autonomy.md
Memória-rendszer (FTS5 + vektor + RRF)docs/memory-system.md
Kanban (auto-breakdown, swimlane, WIP-limit, card-aging)docs/kanban.md
Ügynök-flotta + inter-agentdocs/agent-fleet.md
Föderáció (több példány összekötése, dashboard-menüvel)docs/federation.md
Skill-factory (öntanulás)docs/skill-factory.md
Channels (Telegram / Slack / Discord)docs/channels.md
Printing-press CLI-kdocs/printing-press-cli.md
Skool CLIdocs/skool-cli.md
connectors.hudocs/connectors-hu.md
Vault & titkosításdocs/vault.md
Dream-enginedocs/dream-engine.md
Háttér-feladatokdocs/background-tasks.md
Ütemezett feladatokdocs/scheduled-tasks.md
Költöztetés (másik gépre)docs/MIGRATION.md
Beszélgetés-folytonosságdocs/conversation-continuity.md
Channel reply-guarddocs/channel-reply-guard.md
Telegram haladásjelződocs/telegram-progress-indicator.md
Slack haladásjelződocs/slack-progress-indicator.md
Új asszisztens onboardingdocs/onboarding-uj-asszisztens.md

Öntanulás & Seed-ek

Az ágensek automatikusan tanulnak a munkájukból: komplex feladat vagy hiba-recovery után újrahasznosítható skill-t (recept) írnak maguknak, a meglévőket pedig célzottan patch-elik. A skill-ek token-hatékonyan, 3 szinten töltődnek (progressive disclosure). A flotta-szintű skill-ek és ütemezett feladatok a seed-skills/ és seed-scheduled-tasks/ mappából terjednek minden telepítésre (idempotens: a meglévő testreszabást nem írja felül).

→ Részletek: docs/skill-factory.md

Memória rendszer

Minden ágens saját, réteges memóriával rendelkezik (hot / warm / cold / shared), SQLite-ban tárolva. A keresés hibrid: FTS5 full-text + szemantikus vektor (Ollama nomic-embed-text), RRF-fel fúzionálva. A memóriák salience decay-en mennek át (a régi, nem használt tételek halványulnak, de sosem törlődnek), és minden este napi napló készül. A PreCompact hook a kontextus-tömörítés előtt automatikusan elmenti a fontos döntéseket. A dashboardon gráf-nézet is van.

→ Részletek: docs/memory-system.md

Telepítés

macOS / Linux

cd ~
git clone --branch main https://github.com/Szotasz/marveen.git
cd marveen
./install.sh

Alapértelmezés szerint a dashboard a 3420-as porton indul (http://localhost:3420). Egyedi port beállításához:

./install-linux.sh --port 3421   # vagy: WEB_PORT=3421 ./install-linux.sh

Windows (WSL)

irm https://raw.githubusercontent.com/Szotasz/marveen/main/install-windows.ps1 | iex

Vagy manuálisan:

git clone --branch main https://github.com/Szotasz/marveen.git
cd marveen
.\install-windows.ps1

A Windows telepítő automatikusan beállítja a WSL-t (Windows Subsystem for Linux) és azon belül telepíti a Marveen-t.

Ha a PowerShell ablak bezárul / a telepítő nem jut túl a WSL+Ubuntu lépésen: nyisd meg az Ubuntu-t (Start menü → Ubuntu), majd a WSL Ubuntu shellben futtasd közvetlenül a Linux-telepítőt (a PowerShell wrapper megkerülése):

cd ~ && curl -fsSL https://raw.githubusercontent.com/Szotasz/marveen/main/install-linux.sh -o install.sh && bash install.sh

Ez a megbízható út, ha a wsl.exe/Windows-claude környezet összeakad.

A telepítő végigvezet a beállításokon:

  1. Függőségek ellenőrzése és telepítése
  2. Claude Code bejelentkezés
  3. Telegram bot létrehozása
  4. Személyes beállítások (a bot neve és a termék/márka neve)
  5. Szolgáltatások indítása

Branding (saját márkanév)

A platform szabadon márkázható telepítéskor. Két, egymástól független beállítás:

BeállításMi ezDefault
BOT_NAMEA fő ágens megjelenített neve (pl. MyAssistant)Marveen
BRAND_NAMEA termék / rendszer neve a dashboard fejlécében (böngésző-cím, oldalsáv, mobil topbar)BOT_NAME

A telepítő mindkettőt megkérdezi. Ha csak Entert nyomsz, minden marad Marveen (a viselkedés változatlan a meglévő telepítésekhez képest). Ha külön márkanevet adsz meg, a teljes felület és az OS szolgáltatás-azonosítók is azzal jönnek létre:

# Példa: az ágens neve "MyAssistant", a terméké "AcmeAI"
#   Mi legyen a botod neve? [Marveen]: MyAssistant
#   Mi a termék/márka neve? [MyAssistant]: AcmeAI

A .env-ben ezek a kulcsok jelennek meg (lásd .env.example):

BOT_NAME=MyAssistant
BRAND_NAME=AcmeAI
MAIN_AGENT_ID=myassistant   # belső ágens-azonosító (a BOT_NAME ASCII slug-ja)
SERVICE_ID=acmeai           # OS szolgáltatás-azonosító (a BRAND_NAME ASCII slug-ja)

A MAIN_AGENT_ID és SERVICE_ID értékeket a telepítő automatikusan származtatja; ritkán kell kézzel szerkeszteni. Ha a BRAND_NAME megegyezik a BOT_NAME-mel (a default), a SERVICE_ID megegyezik a MAIN_AGENT_ID-vel, így a launchd/systemd unit-nevek byte-azonosak a márkázatlan telepítéssel: a helyben történő frissítés nem törik el.

Használat

Dashboard

Nyisd meg: http://localhost:3420

Csatorna (Telegram, Slack vagy Discord)

A telepítés során választhatsz csatorna providert (Linuxon és macOS-en is). Az alapértelmezett a Telegram.

Telegram (alapértelmezett)

Írj a botodnak Telegramon -- Marveen válaszol.

Slack (alternatív)

Slack használatához a telepítő automatikusan végigvezet, de manuálisan is beállíthatod:

  1. Hozz létre egy Slack App-ot a Slack API oldalon
  2. Engedélyezd a Socket Mode-ot (Settings > Socket Mode > Enable)
  3. Generálj egy App-Level Token-t (xapp-...) a connections:write scope-pal
  4. Add hozzá a Bot Token Scopes-okat (OAuth & Permissions): chat:write, channels:read, files:write, files:read
  5. Installáld az App-ot a workspace-edbe -- megkapod a Bot User OAuth Token-t (xoxb-...)
  6. Hívd meg a botot a kívánt csatornába (/invite @BotNev)
  7. A .env fájlban állítsd be: `` CHANNEL_PROVIDER=slack SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... SLACK_CHANNEL_ID=C01234ABCDE ``
  8. A Slack channel plugin automatikusan települ: slack@jeremylongshore/claude-code-slack-channel
Discord (alternatív)

A telepítő végigvezet (Linuxon és macOS-en is a 3. opció), de manuálisan is beállíthatod:

  1. Hozz létre egy alkalmazást a Discord Developer Portalon
  2. A Bot fülön add hozzá a botot és másold ki a tokent
  3. Privileged Gateway Intents: kapcsold be a MESSAGE CONTENT INTENT-et
  4. OAuth2 > URL Generator: bot scope, majd hívd meg a botot a szerveredre
  5. Developer Mode-dal másold ki a csatorna ID-t és a saját (operátor) user ID-det
  6. A .env fájlban állítsd be: `` CHANNEL_PROVIDER=discord DISCORD_BOT_TOKEN=... DISCORD_CHANNEL_ID=... OPERATOR_DISCORD_USER_ID=... ``
  7. A Discord channel plugin automatikusan települ: discord@claude-plugins-official

Részletek (managed-settings allowlist, párosítás): docs/channels.md.

A csatorna váltáshoz futtasd újra a ./install.sh-t vagy szerkeszd a .env fájlt manuálisan.

Ágensek

A Csapat oldalon hozz létre új ágenseket. Mindegyik:

  • Saját Telegram bot
  • Saját személyiség (SOUL.md)
  • Saját utasítások (CLAUDE.md)
  • Saját memória és skillek

Telegram bot profilkép

A telepítő automatikusan generál egy pixel-art avatart és Telegramon elküldi neked a beállítási utasításokkal. Ha egyedi képet szeretnél:

  1. Tedd a fájlt agents/<AGENT_NEVE>/avatar.png alá (png/jpg/jpeg/webp)
  2. Indítsd újra a szolgáltatást (./scripts/stop.sh && ./scripts/start.sh)
  3. Az install-flow újra elküldi az avatart a Telegram chatbe

Beállítás a Telegram botodra:

  1. Nyisd meg a @BotFather chatet
  2. Küld a /setuserpic parancsot
  3. Válaszd ki a botodat a listából
  4. Küldd be a kapott képet

A dashboardon (Csapat oldal) is cserélhetsz avatart: kattints a bot kártyájára, válassz a galériából vagy tölts fel sajátot -- a rendszer automatikusan elküldi a Telegram chatbe.

Ütemezések

Időzített feladatok és heartbeat monitorok beállítása:

  • Lista, napi idővonal és heti nézet
  • Feladat: mindig szól az eredménnyel
  • Heartbeat: csendes ellenőrzés, csak fontosnál értesít

Vault & Titkosítás

Az MCP szerverek API kulcsait, tokenjeit és jelszavait egy titkosított Vault kezeli (AES-256-GCM), a master key macOS-en a Keychain-ben (Linuxon fájl-alapú fallback). A .mcp.json-ben csak vault:SECRET_ID referenciák állnak — a plaintext kulcsok nem hevernek olvashatóan. A dashboard Vault-oldalán kezelheted a titkokat, a Scan & Import megtalálja a meglévő plaintext kulcsokat.

→ Részletek: docs/vault.md

Ágens monitorozás

A monitor_agents.sh script összefogja az összes futó ágens tmux session-jét egyetlen monitor session-be, iTerm2 Control Mode-dal (-CC) minden ágens külön iTerm tab-ként jelenik meg.

# Lokálisan (a gépen ahol az ágensek futnak):
./scripts/monitor_agents.sh

# Távolról (laptopról SSH-n, iTerm2-vel):
ssh macmini -t "~/marveen/scripts/monitor_agents.sh"

# Ha új ágens indult és nem látod a monitorban -- kill + újraindítás:
ssh macmini "/opt/homebrew/bin/tmux kill-session -t monitor" && \
  ssh macmini -t "~/marveen/scripts/monitor_agents.sh"

A script automatikusan felderíti a futó agent-* és marveen-channels session-öket. A monitor session törlése nem érinti az ágens session-öket -- csak a linked-window referenciákat szünteti meg.

Remote access key enrollment

A helper that lets an operator enroll a single device's SSH public key with a tightly restricted authorized_keys entry, then hands back a copyable connection bundle. Each device carries its own revocation id (marveen-remote:<uuid>) so access can be replaced or removed per device.

Run it with the public key line as a single quoted argument:

npm run remote-enroll -- "ssh-ed25519 <base64 key> marveen-remote:<uuid>"
# optional flags:
npm run remote-enroll -- --host 203.0.113.10 --port 2222 "ssh-ed25519 <base64 key> marveen-remote:<uuid>"

The public key line must be exactly three fields (type, key, comment) with no authorized_keys options and no extra fields. Only ssh-ed25519 keys are accepted, and the comment must be marveen-remote:<uuid> (uuid v4).

It appends (or replaces, when the same id is re-enrolled) this restricted line to the invoking user's ~/.ssh/authorized_keys:

restrict,port-forwarding,permitopen="127.0.0.1:3420",command="/bin/false" ssh-ed25519 <base64 key> marveen-remote:<uuid>

restrict disables pty, agent, and X11 forwarding; the forced command is /bin/false; and the only endpoint the key may open is 127.0.0.1:3420. The write is atomic (temp file plus rename) and guarded by an authorized_keys.lock file so concurrent runs cannot corrupt the list. ~/.ssh is created 0700 and authorized_keys 0600 when missing; if either already exists with looser permissions the tool warns instead of changing them silently.

After enrolling, it prints a base64 connection bundle between clearly marked delimiters. The bundle carries the host, SSH port and user, the fixed remote port (3420), the device id, the machine's ssh-ed25519 host key, and -- by default -- the dashboard bearer token (DASHBOARD_TOKEN env or store/.dashboard-token), so the connecting app can authenticate against the dashboard without a separate step. A token-bearing bundle is a SECRET: hand it over on a private channel only, never by email or shared chat. Pass --no-dashboard-token to emit a token-free bundle (the device user must then obtain the dashboard access URL out of band). If no token can be found the tool warns and emits a token-free bundle. The host key is looked up in the known public-key locations (/etc/ssh, /private/etc/ssh, Homebrew and /usr/local prefixes) and, when none of those files exist -- as on stock macOS -- read from the running SSH server itself via ssh-keyscan on loopback. The connecting side requires the host key, so if it cannot be obtained from any source the tool exits with an error instead of printing an unusable bundle; start the SSH server (macOS: System Settings > General > Sharing > Remote Login) and re-run. When --host is not given, the tool prints a hint to verify the resolved address is the one the device will reach.

To revoke a device, delete the line whose comment matches its id (marveen-remote:<uuid>) from ~/.ssh/authorized_keys.

Frissítés

./update.sh

Leállítás / Indítás

./scripts/stop.sh
./scripts/start.sh

VPS / AWS EC2 telepítés (szerver)

Linux VPS-en (Ubuntu 22+, Debian 12+) az ./install.sh automatikusan az install-linux.sh-t futtatja. Headless szerveren a bejelentkezéshez OAuth token kell, mert nincs böngésző.

# 1. A SAJÁT gépeden (ahol van böngésző):
claude setup-token
# Másold ki a generált tokent (sk-ant-oat01-...)

# 2. A VPS-en:
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
cd ~
git clone --branch main https://github.com/Szotasz/marveen.git
cd marveen
./install.sh    # automatikusan install-linux.sh-t futtat

A token 1 évig érvényes. Ne állíts be ANTHROPIC_API_KEY-t mellé.

Fontos VPS-specifikus tudnivalók:

  • RAM: legalább 2 GB ajánlott (t3.small). 1 GB-os gépen az npm build swap nélkül elbukhat -- a telepítő figyelmeztet és felajánl swap-létrehozást.
  • claude.ai MCP-k: ha a claude.ai fiókodban sok MCP connector van engedélyezve, a headless claude session megpróbálja betölteni mindet, ami instabilitást okozhat. Telepítés előtt tiltsd le a felesleges MCP-ket a claude.ai Settings oldalán.
  • Közvetlen futtatás: ./install-linux.sh (Linux) vagy ./install-macos.sh (macOS) ha az OS-detekciót ki akarod hagyni.

Követelmények

  • macOS, Linux, vagy Windows 10/11 (WSL-lel)
  • Node.js 20+
  • Claude Code CLI (Claude Max/Pro előfizetés szükséges)
  • Telegram fiók vagy Slack workspace

Közösség és támogatás

Kérdésed van? Csatlakozz az AI a mindennapokban közösséghez:

Támogasd a projektet

Ha hasznos számodra a Marveen, támogasd a fejlesztést:

Támogatás

Köszönet

A Marveen több külső projektre és koncepcióra épít. A teljes felsorolás (forrás, szerző, licensz, hogyan használjuk) az ATTRIBUTIONS.md fájlban található. Köszönet a Perplexity AI-nek (Bumblebee), Artem Zhutovnak (handoff / retrospective / skill-management skill suite), Mike Van Hornnak (printing-press), Andrej Karpathynak (CLAUDE.md pattern), és Matt Pococknak (handoff design tippek) a munkájukért.

Készítette

Szota Szabolcs -- AI konzultáns, az "AI a mindennapokban" csatorna készítője

GitHub

Source 1 files
hooks/register.ts 227 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// agent-state-observer -- an OBSERVE-ONLY Claude Code mod.
4//
5// What it does: on session, turn and tool events it writes this agent's state
6// (starting / idle / working / awaiting_approval / ended), its last 200
7// transitions and its rate-limit readings ($.session.usage()) to one JSON file.
8// What it never does: every hook passes the event on with next(e) and returns
9// next's result unchanged -- it never denies, rewrites or delays a call, and
10// draws nothing. Writes are fire-and-forget; a failed write is swallowed, so
11// the mod can never stall the agent.
12//
13// Identity and output come from the launcher, not from a path convention:
14//   MARVEEN_AGENT_ID           the agent's name (letters, digits, "_" and "-")
15//   MARVEEN_STATE_OBSERVER_DIR the folder it writes <agent>.json into
16// Without both (or with a name that does not fit) it writes nothing at all.
17// Kill switches, no restart needed: <dir>/DISABLE stops every agent,
18// <dir>/DISABLE-<agent> stops that one; the hooks then only pass through.
19
20const AGENT_NAME = /^[a-z0-9_-]+$/i
21const HISTORY_MAX = 200
22
23/** The agent name the launcher gave, or null when it is missing or malformed. */
24export function validAgent(value: string | undefined): string | null {
25  return value && AGENT_NAME.test(value) ? value : null
26}
27const TICK_MS = 60_000
28
29type AgentState = 'starting' | 'idle' | 'working' | 'awaiting_approval' | 'ended'
30
31type RateLimitReading = { kind: string; percentUsed: number; resetsAt?: string }
32
33type Usage = {
34  read_at: number
35  rateLimits: RateLimitReading[]
36  rateLimits_empty: boolean
37  context_percent: number | null
38  cost_usd: number | null
39}
40
41type Snapshot = {
42  v: 1
43  agent: string
44  state: AgentState
45  since: number
46  updated_at: number
47  alive_at: number
48  session_id: string | null
49  turn_id: string | null
50  tool: string | null
51  reason: string | null
52  seq: number
53  history: { ts: number; from: AgentState; to: AgentState; tool?: string; reason?: string }[]
54  usage: Usage | null
55  usage_history: Usage[]
56  first_usage_probe: { at: number; rateLimits_empty: boolean } | null
57}
58
59const now = () => Date.now()
60function freshSnapshot(): Snapshot {
61  return {
62  v: 1,
63  agent: '',
64  state: 'starting',
65  since: now(),
66  updated_at: now(),
67  alive_at: now(),
68  session_id: null,
69  turn_id: null,
70  tool: null,
71  reason: null,
72  seq: 0,
73  history: [],
74  usage: null,
75  usage_history: [],
76  first_usage_probe: null,
77  }
78}
79
80// Module state starts over on every load; register() resets it explicitly too.
81let snap: Snapshot = freshSnapshot()
82
83// Writes are chained so two events never interleave their file writes.
84let chain: Promise<void> = Promise.resolve()
85let disabled = false
86// Set by session.start from the launcher's environment; until then, and when
87// either value is missing, nothing is written.
88let agent: string | null = null
89let stateDir: string | null = null
90
91function flush($: EngineInterface): void {
92  const name = agent
93  const dir = stateDir
94  if (!name || !dir) return
95  const text = JSON.stringify(snap)
96  chain = chain
97    .then(async () => {
98      disabled = (await $.fs.exists(`${dir}/DISABLE`)) || (await $.fs.exists(`${dir}/DISABLE-${name}`))
99      if (!disabled) await $.fs.write(`${dir}/${name}.json`, text)
100    })
101    .catch(() => {})
102}
103
104function move($: EngineInterface, to: AgentState, extra: { tool?: string; reason?: string; turnId?: string } = {}): void {
105  if (disabled) return
106  const t = now()
107  if (extra.turnId !== undefined) snap.turn_id = extra.turnId
108  snap.tool = extra.tool ?? null
109  snap.reason = extra.reason ?? null
110  snap.updated_at = t
111  snap.alive_at = t
112  if (to === snap.state) {
113    flush($)
114    return
115  }
116  snap.history.push({ ts: t, from: snap.state, to, ...(extra.tool ? { tool: extra.tool } : {}), ...(extra.reason ? { reason: extra.reason } : {}) })
117  if (snap.history.length > HISTORY_MAX) snap.history.splice(0, snap.history.length - HISTORY_MAX)
118  snap.state = to
119  snap.since = t
120  snap.seq += 1
121  flush($)
122}
123
124async function readUsage($: EngineInterface, probe: boolean): Promise<void> {
125  if (disabled) return
126  try {
127    const u = await $.session.usage()
128    const reading: Usage = {
129      read_at: now(),
130      rateLimits: (u.rateLimits ?? []).map((r: RateLimitReading) => ({
131        kind: r.kind,
132        percentUsed: r.percentUsed,
133        ...(r.resetsAt ? { resetsAt: r.resetsAt } : {}),
134      })),
135      rateLimits_empty: !(u.rateLimits && u.rateLimits.length > 0),
136      context_percent: u.context?.percent ?? null,
137      cost_usd: u.cost?.usd ?? null,
138    }
139    const prev = snap.usage
140    const changed =
141      !prev ||
142      JSON.stringify(prev.rateLimits) !== JSON.stringify(reading.rateLimits) ||
143      prev.context_percent !== reading.context_percent
144    snap.usage = reading
145    if (changed) {
146      snap.usage_history.push(reading)
147      if (snap.usage_history.length > HISTORY_MAX) snap.usage_history.splice(0, snap.usage_history.length - HISTORY_MAX)
148    }
149    if (probe && !snap.first_usage_probe) {
150      snap.first_usage_probe = { at: reading.read_at, rateLimits_empty: reading.rateLimits_empty }
151    }
152    flush($)
153  } catch {
154    // A usage read failing must not touch the agent; the next tick retries.
155  }
156}
157
158export const register: Register = on => {
159  snap = freshSnapshot()
160  chain = Promise.resolve()
161  disabled = false
162  agent = null
163  stateDir = null
164
165  on('session.start', async ($, e, next) => {
166    const r = await next(e)
167    try {
168      agent = validAgent(await $.env.get('MARVEEN_AGENT_ID'))
169      stateDir = (await $.env.get('MARVEEN_STATE_OBSERVER_DIR'))?.replace(/\/+$/, '') || null
170    } catch {
171      agent = null
172      stateDir = null
173    }
174    snap.agent = agent ?? ''
175    try {
176      snap.session_id = await $.session.id()
177    } catch {}
178    move($, 'idle', { reason: 'session.start' })
179    $.clock.every(TICK_MS, () => {
180      if (disabled) return
181      snap.alive_at = now()
182      flush($)
183      void readUsage($, false)
184    })
185    return r
186  })
187
188  on('turn.start', async ($, e, next) => {
189    move($, 'working', { turnId: e.turnId, reason: 'turn.start' })
190    return next(e)
191  })
192
193  on('tool.check', async ($, e, next) => {
194    const r = await next(e)
195    // Only a real call carries tool_use_id; a $.tool.check query does not.
196    if (e.tool_use_id && r.decision === 'ask') {
197      move($, 'awaiting_approval', { tool: e.tool, reason: r.hook ?? r.rule ?? 'ask' })
198    }
199    return r
200  })
201
202  on('tool.call', async ($, e, next) => {
203    move($, 'working', { tool: String(e.tool), reason: 'tool.call' })
204    return next(e)
205  })
206
207  on('turn.complete', async ($, e, next) => {
208    const r = await next(e)
209    // A subagent's turn ending (agentId set) does not end the main loop's turn.
210    if (e.agentId) return r
211    move($, 'idle', { reason: `turn.complete:${e.reason}` })
212    void readUsage($, true)
213    return r
214  })
215
216  on('session.measure', async ($, e, next) => {
217    const r = await next(e)
218    void readUsage($, false)
219    return r
220  })
221
222  on('session.end', async ($, e, next) => {
223    move($, 'ended', { reason: `session.end:${String(e.reason)}` })
224    return next(e)
225  })
226}
227