No description
  • TypeScript 95.8%
  • Shell 4.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Morten Olsen 566477092e
All checks were successful
ci/woodpecker/push/draft-release Pipeline was successful
ci/woodpecker/push/quality Pipeline was successful
ci/woodpecker/push/compliance Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
docs: rewrite README for users and add curl-able install script
Restructure the README around the user journey (pitch, quick start,
everyday use, hooks, per-agent capability table) with architecture and
monorepo layout moved to the bottom. Add scripts/install.sh, which
resolves the latest release and installs the platform binary to
~/.local/bin, wired into the quick start as a curl | sh one-liner.
2026-09-15 09:35:14 +02:00
.claude-plugin feat: initial agent-driver — cross-agent monitor daemon with pi/omp/claude sensors 2026-09-14 13:29:50 +02:00
.woodpecker feat: publish @agent-driver/client and @agent-driver/protocol on release 2026-09-14 14:02:51 +02:00
adapters fix: bundle pi adapter from workspace src (--conditions=bun) — node-target build resolved exports to unbuilt dist 2026-09-14 14:04:59 +02:00
apps/agentd feat: include assistant context in lifecycle hooks 2026-09-15 00:10:00 +02:00
examples feat: include assistant context in lifecycle hooks 2026-09-15 00:10:00 +02:00
packages feat: include assistant context in lifecycle hooks 2026-09-15 00:10:00 +02:00
scripts docs: rewrite README for users and add curl-able install script 2026-09-15 09:35:14 +02:00
.gitignore ci: woodpecker pipelines — shared bun taskfile, quality/compliance/renovate gates, drafted releases with cross-compiled binaries 2026-09-14 13:54:52 +02:00
.taskrc.yml ci: woodpecker pipelines — shared bun taskfile, quality/compliance/renovate gates, drafted releases with cross-compiled binaries 2026-09-14 13:54:52 +02:00
bun.lock feat: initial agent-driver — cross-agent monitor daemon with pi/omp/claude sensors 2026-09-14 13:29:50 +02:00
mise.toml ci: woodpecker pipelines — shared bun taskfile, quality/compliance/renovate gates, drafted releases with cross-compiled binaries 2026-09-14 13:54:52 +02:00
package.json feat: initial agent-driver — cross-agent monitor daemon with pi/omp/claude sensors 2026-09-14 13:29:50 +02:00
README.md docs: rewrite README for users and add curl-able install script 2026-09-15 09:35:14 +02:00
renovate.json ci: woodpecker pipelines — shared bun taskfile, quality/compliance/renovate gates, drafted releases with cross-compiled binaries 2026-09-14 13:54:52 +02:00
Taskfile.yml fix: configure quality gate lint no-op 2026-09-15 00:15:21 +02:00
tsconfig.json feat: publish @agent-driver/client and @agent-driver/protocol on release 2026-09-14 14:02:51 +02:00

agent-driver

Know what every coding agent on your machine is doing — and react when it needs you.

agent-driver runs a small local daemon (agentd) that tracks the agent sessions on your machine: which exist, what state they're in (working / idle / blocked), and what they last said. On top of that you can:

  • Get notified the moment an agent finishes a turn or gets stuck on a permission prompt — run any script on those transitions (say something, ping your phone, kick off a follow-up).
  • Script against agents from the shell: wait for a session to settle, read its transcript, send a prompt into it.
  • Build tools on the event stream with a small TypeScript client — status bars, dashboards, voice bridges.

It works with pi (@earendil-works/pi-coding-agent, and oh-my-pi) and Claude Code today. Unlike terminal-multiplexer approaches (herdr, claude-squad), it doesn't require agents to run inside a particular terminal: sensors live in each agent's own extension mechanism and report to the daemon over a unix socket, so your existing workflow stays untouched.

Quick start

Install the latest prebuilt binary (macOS/Linux, arm64/x64) into ~/.local/bin:

curl -fsSL https://code.olsen.cloud/utils/agentd/raw/branch/main/scripts/install.sh | sh

AGENTD_INSTALL_DIR overrides the install location, AGENTD_VERSION pins a tag; binaries can also be grabbed by hand from the releases page, or built from source with task install-bin (see Development).

Then install the sensors into every agent found on your machine:

agentd install        # detects pi, oh-my-pi, and Claude Code; installs into each

That's it — no launchd/systemd setup. The daemon is spawned on demand by the first client or hook that needs it. Start an agent session anywhere and check:

agentd list

agentd install [pi|omp|claude ...] limits installation to specific agents. For Claude Code it materializes a plugin under ~/.agent-driver/claude-plugin and registers it via claude plugin; for pi/omp it writes an extension into ~/.<agent>/agent/extensions/.

Everyday use

agentd list                                   # all known agent sessions
agentd get .                                  # the session running in this directory
agentd tail --key pi:01a0 --event agent.message   # filtered live event stream (--json for NDJSON)
agentd wait . --until idle && say done        # block until a session settles (exit 2 on timeout)
agentd prompt pi:01a0 "run the tests again"   # send a prompt into a pi session
git diff | agentd prompt . -                  # pipe a prompt in via stdin
agentd transcript . --turns 3                 # normalized transcript, last 3 turns
agentd status | stop | serve                  # daemon lifecycle

Sessions are selected by full key (<agent>:<sessionId>), an unambiguous key prefix, . (the session in the current directory), or --cwd <path>. Omitting the selector on a TTY opens an interactive picker (arrows/j/k, 1-9, enter; esc/q cancels with exit 130; a lone session is used directly). The menu renders on stderr, so stdout stays clean for piping even while picking.

The "react when an agent completes" pattern:

agentd wait . --until idle && agentd transcript . --turns 1 --role assistant

Transcripts are normalized in the daemon (pi's branching session format, Claude's internal JSONL), so you always get clean {role, text, ts} entries; consecutive same-role entries are merged so one turn reads as one entry.

Lifecycle hooks

The simplest way to react to agents: drop executable scripts at either of these locations and agentd runs them on state transitions —

~/.agent-driver/hooks/{start,idle,blocked,exit}
<agent-cwd>/.agent-driver/hooks/{start,idle,blocked,exit}

For example, to hear when any session goes idle:

mkdir -p ~/.agent-driver/hooks
cat > ~/.agent-driver/hooks/idle <<'SH'
#!/bin/sh
say "$AGENT_DRIVER_SESSION_KEY is idle"
SH
chmod +x ~/.agent-driver/hooks/idle

For each lifecycle event, agentd launches the global hook and then the hook belonging to the session's cwd, when those files exist and are executable (AGENT_DRIVER_HOME replaces ~/.agent-driver when set). Hooks run asynchronously with the agent cwd as their working directory, so they never delay state reporting. A hook fires once when the session registers (start), when its state transitions to idle or blocked, and when it transitions to ended (exit); a detail-only update that leaves the state unchanged does not fire it again.

The event name is passed as the first argument and in AGENT_DRIVER_HOOK, alongside:

  • AGENT_DRIVER_AGENT
  • AGENT_DRIVER_SESSION_ID
  • AGENT_DRIVER_SESSION_KEY
  • AGENT_DRIVER_CWD
  • AGENT_DRIVER_STATE
  • AGENT_DRIVER_PREVIOUS_STATE (state transitions only)
  • AGENT_DRIVER_STATE_DETAIL (when present)
  • AGENT_DRIVER_LAST_ASSISTANT_MESSAGE (when known; capped at 16 KiB)

examples/blocked-ntfy-macos is a ready-to-customize blocked hook that publishes the block reason and most recent assistant message through ntfy — only when the Mac screen is locked and there is no active inbound SSH session, so you're pinged on your phone exactly when you're away from the keyboard:

install -m 755 examples/blocked-ntfy-macos ~/.agent-driver/hooks/blocked

Set NTFY_TOPIC (and optionally NTFY_TOKEN or NTFY_SERVER) in the daemon's environment, or edit their defaults near the top of the installed script. Use a private, hard-to-guess topic when publishing through the public ntfy server.

Supported agents

pi / oh-my-pi Claude Code
State tracking (working/idle) ✅ authoritative (extension events) ✅ via plugin hooks
Blocked detection (permission prompts) ⚠️ needs pi with ui_prompt_* events (not in 0.84.x) ✅
Last / full message stream ✅ every message ✅ final assistant message per turn
Prompt injection (agentd prompt) ✅ ❌ no documented injection path yet
Sessions started before the daemon ✅ on next event ✅ discovered via session registry watcher

For Claude Code, the daemon additionally watches Claude's (internal, undocumented) ~/.claude/sessions/*.json registry for discovery and live busy/idle enrichment; disable with AGENT_DRIVER_NO_CLAUDE_REGISTRY=1. Enrichment sources never override an agent's own authoritative reports.

State model

working (executing a turn) · idle (waiting at the prompt) · blocked (waiting on a permission/confirmation dialog) · ended · unknown. "Agent finished" is the working → idle transition, typically paired with an agent.message carrying the final assistant text.

Building on the API

Programmatic subscribers use @agent-driver/client, published (with @agent-driver/protocol) to the internal registry on every release. In a consumer project, commit an .npmrc with the scope routing and install:

@agent-driver:registry=https://code.olsen.cloud/api/packages/utils/npm/
bun add @agent-driver/client   # or npm/pnpm — dist js + d.ts is shipped

The client connects to the daemon — or spawns it — and streams events:

import { AgentDriverClient } from "@agent-driver/client/client";

const client = await AgentDriverClient.connectOrSpawn();
client.onEvent("agent.state_changed", ({ session, prevState }) => {
  if (prevState === "working" && session.state === "idle") {
    console.log(`${session.key} finished:`, session.lastMessage?.text);
  }
});
await client.subscribe();

Sessions are addressed by key ("<agent>:<sessionId>") in every method except agent.register. The full wire contract lives in @agent-driver/protocol: methods (agent.register, agent.list, agent.prompt, agent.wait, agent.transcript, events.subscribe, …) and events (agent.registered, agent.state_changed, agent.message, agent.deregistered). The protocol is newline-delimited JSON over the unix socket at ~/.agent-driver/agentd.sock, so any language can speak it directly.

To add support for a new agent, write a sensor with registerSensor() — it owns registration, daemon reconnection (re-registering with the last reported state), and inbound prompt routing, so an adapter is pure event mapping:

import { registerSensor } from "@agent-driver/client/sensor";

const session = await registerSensor({
  agent: "myagent", sessionId, cwd,
  onPrompt: (text, deliverAs) => injectIntoAgent(text),
});
session.state("working");
session.message("assistant", finalText);
await session.end();

State reports carry a source rank: authoritative (the agent's own hooks/extension, the default) or enrichment (watchers, scrapers) — enrichment never overrides an authoritative blocked.

How it works

┌─────────────┐  pi extension (socket, bidirectional)   ┌────────────┐
│ pi session  │────────────────────────────────────────▶│            │
└─────────────┘                                         │   agentd   │──▶ subscribers
┌─────────────┐  plugin hooks → `agentd report claude`  │  (daemon)  │    (tail, voice
│ claude code │────────────────────────────────────────▶│            │     bridge, …)
└─────────────┘                                         └────────────┘
        ▲                                                     │
        └── ~/.claude/sessions/*.json watcher (enrichment) ───┘

Daemon and CLI are one binary with the adapters embedded. The pi adapter is a single-file extension that reports authoritative state via agent_start/agent_settled, streams messages via message_end, and delivers prompts back into the session via pi.sendUserMessage (the same bundle serves any pi-compatible host — sessions register as pi:<id> or omp:<id> based on the host's data dir; AGENT_DRIVER_AGENT overrides). The Claude adapter is a plugin shipping lifecycle hooks (SessionStart, UserPromptSubmit, Stop, Notification, SessionEnd) through a POSIX-sh shim into agentd report claude; the Stop payload's last_assistant_message lands as the session's last message without any transcript parsing.

Current limitations

  • Prompt injection is pi-only. Claude Code has no documented injection path into a running interactive session; candidates (undocumented peer socket, tmux send-keys) are future adapter work.
  • pi blocked detection needs pi ≥ the version that ships ui_prompt_* events (not in 0.84.x).
  • The socket has no auth beyond filesystem permissions (0700 home dir); any same-user process can subscribe and prompt.

Development

Monorepo layout: apps/agentd (daemon + CLI), packages/protocol (wire contract), packages/client (SDK, pure node:* APIs — runs under Node and Bun), adapters/pi and adapters/claude (sensors).

task install   # bun install
task check     # typecheck
task test      # bun test (integration suite over a real socket)
task build     # compiled binary + pi extension bundle
task install-bin   # build + copy agentd to ~/.local/bin

Per-adapter task install-pi | install-omp | install-claude targets install a locally built sensor for development.