- TypeScript 95.8%
- Shell 4.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .claude-plugin | ||
| .woodpecker | ||
| adapters | ||
| apps/agentd | ||
| examples | ||
| packages | ||
| scripts | ||
| .gitignore | ||
| .taskrc.yml | ||
| bun.lock | ||
| mise.toml | ||
| package.json | ||
| README.md | ||
| renovate.json | ||
| Taskfile.yml | ||
| tsconfig.json | ||
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_AGENTAGENT_DRIVER_SESSION_IDAGENT_DRIVER_SESSION_KEYAGENT_DRIVER_CWDAGENT_DRIVER_STATEAGENT_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
blockeddetection needs pi ≥ the version that shipsui_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.