awareness
kolu knows what your agents are doing.
kolu watches the foreground process of every terminal. When that process is a
coding agent it recognizes — Claude Code,
Codex, Grok Build,
OpenCode,
Pi,
Xyne, or
Oh My Pi (omp) — it reads that agent’s
own on-disk session to derive whether it’s thinking, running a tool, waiting on
you, or done. That state paints the tile chrome, the dock row, and the minimap,
so a glance across a workspace of twenty terminals tells you who needs you.
There is no agent registry to configure and no per-agent setup. Run a supported agent in any terminal and detection lights up on its own.
One state vocabulary
Every agent folds onto the same small set of states, drawn with the same glyphs everywhere kolu shows agent status. The shape carries the meaning, so the distinction survives a peripheral glance or reduced color sensitivity.
The awaiting-you hue sits deliberately outside the warm/pending family, so an agent that needs you never reads as a build still churning. When an agent finishes in the background and you haven’t looked yet, its whole row washes amber — and a small amber unread badge appears on the pip — until you focus it.
How detection works
The pipeline is the same for every agent; only the last step — which on-disk store holds the session — differs.
kolu asks each terminal for its foreground process id (tcgetpgrp, POSIX — Linux
and macOS alike), matches it against the known agents, and reads that agent’s
session from disk — tailing a JSONL transcript, or querying a SQLite database and
watching its write-ahead log. Those file-change watches are backed by a low-rate
stat reconciliation, so if the OS drops the change notification for a fast turn’s
final write, the state self-heals within about a second instead of stranding on
its last reading. In a normal shell terminal that’s enough: the shell
prints a marker naming each command as it runs — part of kolu’s
shell integration — and the agent’s process becomes
the foreground. When an agent is launched as the terminal’s root process — no
shell wrapping it, e.g. kaval-tui create -- codex — there is no shell to print
that marker, so kolu instead remembers the exact command the terminal was
spawned with and uses it as the recognition hint (and reads that root’s
foreground as busy, since the agent is the root). kolu never scrapes a running
process’s argv from /proc; it uses only the shell’s marks and the command it was
handed at spawn. Adding a new agent is one small package that declares its
AgentVocab (name, mark, CLI grammar, resume policy, info schema) and its
AgentPlugin (detection adapter, transcript fetcher, env keys), plus one row in
the agent registry and the build wiring (nix/workspace.nix, lockfile) — no
shared table anywhere in the tree changes.
Claude Code
kolu finds a Claude session by checking whether ~/.claude/sessions/<foreground-pid>.json
exists, then tails its JSONL transcript to derive state. The tile also shows the
session’s title and a running token count.
Model and context tokens are read from the newest assistant message
rather than from the newest entry of any kind. A user entry is the newest one
for as long as the agent is generating after a prompt, and again after every
tool call (a tool result is a user entry), so a single-entry reading blanked
both through most of a working turn — the model tag came and went per tool
round-trip. State still comes from the newest entry of either kind; the two
session facts carry forward.
| State | Indicator | Meaning |
|---|---|---|
| Thinking | pulsing accent dot | an API call is in flight — Claude is generating |
| Tool use | pulsing amber dot | Claude is executing tools |
| Running in background | spinning teal ring | Claude ended its turn while a dynamic workflow it launched is still running — busy-waiting, not blocked on you |
| Awaiting input | pulsing alert | blocked on you — an AskUserQuestion prompt or a tool-permission gate |
| Waiting | dim dot | finished responding (or interrupted with Esc) and idle |
The awaiting-input state is the subtle one: while a question or permission prompt is pending, the transcript doesn’t reveal it, so kolu reads the rendered screen for the framework’s prompt chrome and buckets it as awaiting you.
What it can’t detect:
- Plan approval (
ExitPlanMode) — its on-screen shape has no arrow-nav footer to anchor on yet; a deliberate follow-up. - Streaming progress — only final state transitions, not intermediate tokens.
- Wrapped invocations — launched via a wrapper (e.g.
script -q out.log claude), the foreground pid is the wrapper, so the session lookup misses. - Sub-agents — nested spawns aren’t tracked as separate sessions. (When the parent waits on a launched dynamic workflow, kolu does surface that workflow’s name and live sub-agent count.)
Codex
When the foreground process is codex, kolu queries Codex’s threads SQLite
database (the highest-numbered ~/.codex/state_<N>.sqlite) for the live threads
whose working directory matches the terminal — most recently updated first — takes
the best one no other terminal already holds, and tails that thread’s rollout JSONL
for state and context-token count. Live updates ride a watch on the SQLite
write-ahead log.
| State | Indicator | Meaning |
|---|---|---|
| Thinking | pulsing accent dot | latest lifecycle event is task_started, no open tool call |
| Tool use | spinning amber | a function_call is open that isn’t an awaiting-user tool |
| Awaiting input | pulsing alert | every open call is an awaiting-user tool — request_user_input, request_permissions, or request_plugin_install |
| Waiting | dim dot | latest lifecycle event is task_complete |
The context count is Codex’s own last_token_usage.input_tokens — the number its
/status command shows — not a sum that would double-count cache re-reads.
What it can’t detect (yet):
- Task-progress checklist — Codex has no user-visible todo list to read.
- Column-schema changes — if an upstream release renames a depended-on column,
detection returns no match; set
KOLU_CODEX_DBto pin a known-good database while a fix ships. - Which thread is which — Codex records no process id, only a thread creation
time, so with several threads in one directory kolu can guarantee that no two
terminals show the same one and that a terminal ends up on a thread created
after its own harness started — not which of several such threads is really its
own. A thread started inside a running Codex (
/new) is therefore indistinguishable from one started elsewhere in that directory, so the tile stays on the previous thread until you exit the agent.
Grok
When the foreground process is grok, kolu prefers the entry in
~/.grok/active_sessions.json whose pid matches the terminal; with no pid to go
on it takes the most recently updated session under ~/.grok/sessions/ that no
other terminal already holds. That
file is only ever used to find a session, never to decide one has ended —
Grok rewrites the whole list from each session’s own point of view, so a session
starting or ending erases the entries of every other Grok running at that moment.
Once a terminal is matched, only the process going away — or the terminal moving
on to something else — ends the match. State comes from
tailing that session’s events.jsonl — phase_changed for thinking / tool use /
awaiting you, turn_ended for waiting. An open ask_user_question promotes to
awaiting you even mid-tool, so a multiple-choice prompt never reads as “running
tools”.
Resume uses grok -c (most recent in the directory) or grok --resume <uuid>
(an exact conversation). Launch-shape flags such as --model / -m,
--always-approve, --no-plan, --no-subagents, and --no-alt-screen
(inline TUI instead of the alternate screen) are kept on the recent agents
list and on sleep/wake resume. Context tokens come from signals.json
(contextTokensUsed); task progress stays null until Grok’s plan checklist
is a stable first-class count. Transcript export reads chat_history.jsonl
(unwraps Grok’s <user_query> harness so the HTML shows the human prompt).
OpenCode
When the foreground process is opencode, kolu queries OpenCode’s SQLite database
directly at ~/.local/share/opencode/opencode.db for the live sessions whose
directory matches the terminal — most recently updated first — and takes the best
one no other terminal already holds. The database’s write-ahead log drives live
updates.
| State | Indicator | Meaning |
|---|---|---|
| Thinking | pulsing accent dot | latest assistant message has no time.completed |
| Tool use | spinning amber | a running part whose tool is neither question nor plan_exit |
| Awaiting input | pulsing alert | every running part is question or plan_exit — blocked on a reply |
| Waiting | dim dot | latest assistant message is completed with finish: "stop" |
Todo progress comes free from a count over OpenCode’s todo table, and the tile
shows the model and the running token count OpenCode pre-sums itself — both read
from the newest assistant message, since a message of any kind can be the
newest row and neither fact belongs to one message.
What it can’t detect (yet):
- Which session is which — OpenCode records no process id, only the time its first message was written, so with several sessions in one directory kolu can guarantee that no two terminals show the same one and that a terminal ends up on a session begun after its own harness started — not which of several such sessions is really its own. A session started inside a running OpenCode is therefore indistinguishable from one started elsewhere in that directory, so the tile stays on the previous session until you exit the agent.
- Non-default database location — set
KOLU_OPENCODE_DBto override the path.
Pi
When the foreground process is pi, kolu scans the session directory for the
terminal’s working directory — ~/.pi/agent/sessions/--<directory>--/ — offers
every session there as a candidate, most recently updated first, and binds the
one no other terminal already holds. State then comes from tailing that
session’s JSONL transcript. A watch over the sessions tree covers the race at
launch: the shell’s marker names pi before pi has written its file, so it is
the tree event — not another prompt — that lights the tile the moment the file
lands.
| State | Indicator | Meaning |
|---|---|---|
| Thinking | pulsing accent dot | newest entry is a prompt or a tool result — pi persists an assistant message only when it completes, so a mid-turn tail always reads as work in flight |
| Tool use | spinning amber | newest assistant message ended stopReason: "toolUse" |
| Waiting | dim dot | newest assistant message ended its turn (stop / length / error / aborted) |
Resume uses pi -c (most recent in the directory) or pi --session
with the transcript’s absolute path when known (an exact conversation —
pi accepts both the id and the path; the path is spliced because an id alone
can no longer be found once the session store was moved by a redirect
above). Launch-shape flags such as --model / --provider /
--thinking / --name are kept on the recent agents list and on sleep/wake
resume. Context tokens come from the newest assistant message’s usage
(input + cacheRead + cacheWrite — pi’s buckets are disjoint, so the sum is
the full context the model saw, never a double count). The title is the
session’s /name when the user set one. Transcript export reads the same
session JSONL.
What it can’t detect:
- Awaiting you — pi’s permission gates and questions are TUI dialogs that never land in the session file, so there is no on-disk signal to fold from.
- Task-progress checklist — pi has no todo primitive to read.
- Which session is which — like Codex and OpenCode, pi records no process
id (its session id lives only in the file’s name), so with several sessions
in one directory kolu can guarantee that no two terminals show the same one
and that a terminal ends up on a session created after its own harness
started — not which of several such sessions is really its own. A session
started inside a running pi (its
/new) is therefore indistinguishable from one started elsewhere in that directory, so the tile stays on the previous session until you exit the agent. - An ephemeral session — a pi launched with
--no-sessionwrites nothing to disk, so there is no session to bind to (it never enters Recent agents and can’t be restored on wake).
Pi’s session-store locations
kolu follows pi’s own precedence for where sessions live, per terminal — it reads the foreground pi process’s own arguments and environment, so each of these is honoured exactly as pi honours it:
| Override | Where sessions land |
|---|---|
pi --session-dir <dir> | <dir> |
PI_CODING_AGENT_SESSION_DIR=<dir> | <dir> |
sessionDir in <agent dir>/settings.json | <dir> |
PI_CODING_AGENT_DIR=<dir> | <dir>/sessions — the config dir moves, sessions with it |
| (none) | ~/.pi/agent/sessions |
Only the default bottom row uses per-directory subfolders (--<directory>--);
an explicit override in the first three rows holds the session files flat,
attributed by each file’s header — kolu maps on disk exactly as pi does.
Detection follows the redirect: a pi launched by an agent harness that sets
PI_CODING_AGENT_DIR per run (to a scratch dir, for config isolation) is
detected in that run’s real session store, not in ~/.pi. Session-file
updates in any of these stores wake the tile the same way the default tree
does. Two blind spots where a process’s env can’t be read: a host that
hides process environments from kolu itself (e.g. /proc mounted
hidepid), and every macOS host — ps has redacted even same-user
process environments since macOS 10.13, so a PI_CODING_AGENT_SESSION_DIR
redirect is unrecoverable there (--session-dir flags and the default-root
settings.json still resolve). In either case the default tree is scanned,
and only a pi with a default store is found. kolu’s own scan root can be
forced with KOLU_PI_DIR=<dir> (where
<dir>/sessions is the tree to scan) — the escape hatch for tests and
unusual setups, never a replacement for the per-terminal resolution above.
Oh My Pi
Oh My Pi (omp) is a fork of pi that grew its own storage and — the part that
makes it the easiest agent to pin down — a per-terminal breadcrumb: whenever
it creates or switches sessions it writes <tty> → the session file it is
writing, under its agent directory. So detection is not a directory guess at
all. When the foreground process is omp, kolu reads that process’s terminal
(/proc/<pid>/fd/0 on Linux, ps on macOS), reads the matching breadcrumb, and
follows it to the one session this terminal owns — no candidate list, no
ownership arbiter, and two omp terminals in one repository each show their own
conversation by construction. State then comes from tailing that session’s JSONL
transcript, exactly as with pi.
| State | Indicator | Meaning |
|---|---|---|
| Thinking | pulsing accent dot | newest entry is a prompt or a tool result — omp persists an assistant message only when it completes |
| Tool use | spinning amber | newest assistant message ended stopReason: "toolUse" |
| Awaiting input | pulsing alert | a tool-approval dialog (Allow tool: …) or an ask question is on the rendered screen |
| Waiting | dim dot | newest assistant message ended its turn (stop / length / error / aborted) |
Awaiting-you works here because omp paints it: an approval gate or an ask
question renders a bordered dialog kolu recognizes on the rendered screen
(the same mechanism Claude Code uses), while the transcript behind it stays on
the in-flight tool call. The recognition keys on the default theme’s border
corner (╭─ Allow tool: …, ╭─ Ask …): with a non-default box preset (omp’s
symbolPreset: ascii, which draws +- corners) the dialog is not recognized
and the tile stays on running tools for the duration of the prompt. A
recognition loose enough to take any corner risks reading prose as a prompt,
so the default corner is what kolu commits to recognizing.
Resume uses omp -c (this terminal’s breadcrumb, else the most recent
conversation in the directory) or omp --resume with the transcript’s
absolute path when known — the breadcrumb already carries it, and it is the
one reference that survives a moved store. Launch-shape flags such as --model,
--smol, --slow, --plan, --thinking, --approval-mode, and --profile
are kept on the recent agents list and on sleep/wake resume. Context
tokens come from the newest assistant message’s usage (input + cacheRead + cacheWrite — disjoint buckets, so the sum is the full context the model saw).
The title is the session’s own title slot: the name you set, or the one omp
auto-generated after the first turn. Transcript export reads the same
session JSONL, rendering the active branch of omp’s entry tree.
What it can’t detect:
- Plan approval — omp’s plan-review overlay has no stable on-screen marker
yet; a deliberate follow-up, like Claude Code’s
ExitPlanMode. - Task-progress checklist — omp’s
todotool keeps its list in the tool traffic rather than a first-class count kolu reads today. - An ephemeral session —
omp --no-sessionwrites nothing to disk and no breadcrumb, so there is nothing to bind to (it never enters Recent agents and can’t be restored on wake). A breadcrumb an earlier run left behind in that terminal is ignored too, rather than re-binding the tile to the stale conversation’s session. - A redirected store, on macOS only — a
PI_CODING_AGENT_DIR(orOMP_PROFILE) set on theompprocess is unrecoverable there, becausepsredacts even same-user process environments since macOS 10.13, so kolu reads the default directory. A--profileflag still resolves — it lives in argv — and--session-dirnever mattered here: the breadcrumb carries the session’s absolute path whatever store it landed in.
Oh My Pi’s agent-directory locations
kolu follows omp’s own precedence, per terminal, reading the foreground
omp process’s own arguments and environment:
| Override | Where the breadcrumb and sessions land |
|---|---|
omp --profile <name>, else OMP_PROFILE, else PI_PROFILE | ~/.omp/profiles/<name>/agent |
PI_CODING_AGENT_DIR=<dir> | <dir> |
PI_CONFIG_DIR=<name> | ~/<name>/agent |
none of the above, and $XDG_STATE_HOME/omp exists | $XDG_STATE_HOME/omp (a profile uses $XDG_STATE_HOME/omp/profiles/<name>) |
| (none) | ~/.omp/agent |
Unlike pi, an unresolvable directory is not replaced by a kolu default: the
breadcrumb is the only anchor kolu has, so a run whose process cannot be read
simply publishes nothing rather than pointing at a tree this terminal does not
write to. A named profile that omp itself would refuse (an invalid name) is the
same answer. The one exception is macOS’s redacted environment — an env-var
redirect (OMP_PROFILE, PI_CODING_AGENT_DIR) cannot be seen there, so the
default directory is read and only a plain default-profile omp is found, the
same blind spot pi has. kolu’s own default can be forced with
KOLU_OMP_DIR=<dir> — the escape hatch for tests and unusual setups.
Xyne
When the foreground process is xyne, kolu matches the terminal against the
newest transcript under ~/.xyne/agent/sessions/<encoded-cwd>/ — Xyne writes
one <timestamp>_<uuid>.jsonl transcript (plus a <name>_summary.json
sidecar with the display title) per session, in a directory named after the
transcript’s original working directory with every non-alphanumeric character
folded to -. That newest transcript is the same session xyne --continue
would reopen, so it is the session identity kolu reports.
Xyne’s state is always waiting. The persisted transcript carries no live
phase signal — no event stream, no pid map, no in-progress marker — so kolu
never claims to know whether Xyne is mid-turn or idle, and the badge sits on
the dim waiting dot for the session’s whole life. The dock row and agent
inspector still show the session title (from the sidecar) and the
last-used model (from the transcript’s newest model_change entry);
task progress and context tokens stay null by the same honesty. A xyne
badge that reads waiting does not mean it’s your turn — it’s the floor every
turn returns to.
Resume uses xyne --session <uuid> (an exact conversation, including a
cold-restored terminal from before the reboot — the UUID filename is the id
upstream’s flag takes) or xyne --continue (most recent). Transcript
export reads the same session JSONL, taking token usage from the freshest
assistant row’s usage.
Adding an agent
Because detection is agent-agnostic, a new CLI doesn’t wait on kolu. Once you’ve run a known agent in any terminal it also surfaces in the command palette — as a launch option in the worktree-naming flow and in the Recent agents group.
Every fact about an agent lives in that agent’s own package
(packages/integrations/<agent>/), as two values:
<agent>Vocab(browser-safe) — the display name, the brand mark (one 24×24 mark serves both the tile chrome and the dock pip), the CLI grammar that recognizes and normalizes an invocation, the resume policy, and the agent’sAgentInfoschema.<agent>Plugin(node) — the detection adapter, the transcript fetcher, and the env keys that point detection at fixtures.
They are folded in by kolu-agents, the one registry that lists kolu’s
agents. Adding an agent is therefore one package plus one row in that
registry. Everything downstream — the kind enum, the AgentInfo union, the CLI
tables, the tile icon, the dock pip, the transcript dispatch, the sensor
registration, the env-key forwarding — is derived from the registry and edited
by no one. Because AgentVocab requires every facet, an agent that forgets its
resume policy or its mark fails to compile in its own package; and a
governance test forbids any other package from depending on an agent package
directly (anyagent, the kernel, names no agent at all).