lazyshell

AI agent sessions

A session whose foreground process is a known AI coding agent CLI gets a gutter marker showing its detected state — idle, working, blocked (waiting on you) or done — instead of only the generic activity marker.

The problem

The generic activity marker () cannot tell "it produced output" from "it wants an answer". With three agents open, that is exactly the distinction you need: two of them are quietly working, the third has been waiting on a permission prompt for ten minutes.

MarkerStateMeaning
·idleAgent detected, idle.
workingA turn is in progress.
blockedThe agent is waiting on an answer from you.
doneThe agent finished its turn.

Config-free detection

The claude, codex and opencode CLIs are recognised with no setup at all: lazyshell reads the built-in manifests (under pkg/agent/manifests) against the session's visible screen and the terminal title it sets. Nothing to install, nothing to declare.

Custom manifests

Drop a <process-name>.yml file in ~/.config/lazyshell/agents/ (or your $XDG_CONFIG_HOME equivalent) to override a built-in manifest or add one for another agent — the same file name as a built-in replaces it outright, a different name adds to the set. See the built-in manifests for the format.

Manifests are local only: lazyshell never fetches one over the network.

Authoritative state via hooks

Manifest detection is a guess from what is on screen. A second channel lets the agent say its state outright. Every session gets its own Unix socket, exposed to the process running inside it as $LAZYSHELL_SOCK (alongside $LAZYSHELL_SESSION_ID), and lazyshell hook <state> — one of idle, working, blocked or done — writes to it.

It is meant to be wired into the agent's own hook mechanism, not typed by hand:

$ lazyshell init --agents   # prints the config to paste into Claude Code / Codex

Claude Code

A hooks block in settings.json:

Codex

A notify line in config.toml. Codex has only one event (agent-turn-complete), so it can only ever report done.

opencode

Not wired up yet: its richest signal is an SSE subscription rather than something it pushes on its own — a different shape of integration, left for later.

Once a session has received a single hook event, manifest-based guessing stops for that session for good: the hook is authoritative from then on, not just until the next screen change.

Agent control API

The hook channel above only lets an agent describe itself. Letting one act — create sessions, read what the others printed, type into them, or wait for one to reach a given state — is a separate feature on a separate socket, off unless control.enabled says otherwise. It is what an "orchestrator" agent needs.

lazyshell ctl list                                # id, name, status, agent state
lazyshell ctl read session-2 --tail 40            # plain text, no escape codes
lazyshell ctl new --name build --cwd ./api --command 'make test'
lazyshell ctl send build 'echo bonjour' --enter   # as if typed
lazyshell ctl kill build
lazyshell ctl rename build tests

Groups (see Groups) are readable and writable from here, which is what lets one agent orchestrate several others as a unit:

lazyshell ctl list --group agents                  # only that group
lazyshell ctl new --name w1 --group agents
lazyshell ctl group build agents                  # put a session in a group
lazyshell ctl ungroup build                       # take it back out
lazyshell ctl group-send agents 'git pull' --enter
lazyshell ctl group-kill agents

The two fan-out verbs print how many sessions they reached. A group that is empty or does not exist is an error, never a silent "0 sessions": a caller that typo'd a group name must not be told its kill succeeded. group-send skips sessions that have already exited. Restarting a group is deliberately not exposed — there is no restart verb for a single session either; it stays the W key, in the interface.

wait blocks until a session — or, with --group instead of a target, the first member of that group — reaches a given agent state, instead of an orchestrator having to loop on ctl list:

lazyshell ctl wait build --state blocked --timeout 300
lazyshell ctl wait --group agents --state blocked   # returns on the first one that blocks

--timeout is in seconds and optional, defaulting to 120; a timeout, an unknown session/group/state, or the targeted session exiting before reaching the state are all reported as a failure — same non-zero exit as any other ctl error, never a hang.

A session is named by its id (session-2, the value of $LAZYSHELL_SESSION_ID) or by its exact name; --json prints the raw response. Unlike lazyshell hook, which always exits 0 so it can never break an agent's turn, ctl exits non-zero on any failure — a caller that asked for a session and did not get one has to find out. ctl new deliberately does not steal the selection or the keyboard the way pressing n does.

Read this before enabling it. There is no token and no per-session permission: the socket's 0600 file permissions are the entire access control. Turning this on means every process running under your account can create sessions, type commands into them and read their output — not just the agents you started inside lazyshell. And ctl read returns scrollback verbatim, secrets included: the environment tab's masking has no equivalent here, because a credential echoed into a shell is indistinguishable from any other text once it is on screen.

Notifications

A session going blocked or done fires a desktop notification — OSC 9 and OSC 777 to the host terminal by default (both sent unconditionally; a terminal that does not understand one just ignores it) — or the command in notify.fallback_command instead, with the notification text on its stdin, for a terminal that needs one.

At more than a couple of agent sessions open, B jumps the selection straight to the next blocked one, cycling and wrapping — the point of the marker and the notification both.

A non-agent session notifies the same way when its last command — per shell-integration OSC 133 — exits non-zero, so a build or a long-running script does not need to be watched to know it failed. An agent session never fires this second notification on top of its own.

Pattern watchers

Pattern watchers generalize that idea to any session: a regex evaluated against every output line, on top of exit-code detection rather than replacing it — useful for a dev server that logs an error and keeps running, rather than exiting. Declare them in a project file:

sessions:
  - name: api
    watch:
      - pattern: "ERR!"
        notify: true

or arm one on the fly on the selected session with v — a single, replaceable pattern per session, on top of whatever the project file already declared for it; submitting an empty pattern disarms it. Either way, a match notifies through the same OSC/fallback-command channel as everything above, at most once every 3 seconds per pattern — a log spraying the same match 200 times in a burst still fires one notification, not 200. Matching is against the visible text (escape codes stripped) and pauses while a full-screen application (vim, htop) holds the session, the same alt-screen rule OSC 133 already follows.

Turn duration and stats

A session currently mid-turn (working) shows how long its turn has been running in the sessions list, e.g. ⏱ 1m32s.

Setting agent_stats_command runs that command for the selected session only (at most once every 5 seconds — it is meant for something like a token/cost lookup, not something cheap enough to run per session on every tick) with $LAZYSHELL_SESSION_ID in its environment, and shows its first line of output next to the duration — the same "external command, show its output line" shape as Claude Code's own statusLine.

# ~/.config/lazyshell/config.yml
agent_stats_command: "~/bin/my-token-counter.sh"

What lazyshell does not do