Skip to content

feat: agent status (OSC 9001) + terminal in-band protocol features - #156

Open
matej21 wants to merge 16 commits into
mainfrom
feat/agent-status
Open

feat: agent status (OSC 9001) + terminal in-band protocol features#156
matej21 wants to merge 16 commits into
mainfrom
feat/agent-status

Conversation

@matej21

@matej21 matej21 commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

A batch of in-band terminal-protocol features on this branch, headlined by
Agent Status (OSC 9001) — the focus of this session (final commit fc9d724e).

Agent Status (OSC 9001) — new this session

Report what an AI coding agent (Claude Code, Codex, …) is doing in each pane via
a push-based, in-band escape sequence, and remember/resume its session across
restarts.

Display & protocol

  • Per-tab lifecycle indicator, a cross-project sidebar AGENTS section (click
    to jump), a per-project terminal_agent_status field in the remote API, and a
    desktop notification on blocked/done. Runtime-only.
  • Bundled Claude Code plugin (integrations/claude-code/) maps lifecycle
    hooks → states; OKENA_TTY lets hooks without a controlling terminal emit the OSC.

Session capture, persistence & resume (harness-extensible)

  • Reserved lbl keys agent/session_id/transcript_path → a sticky per-pane
    AgentSession (UUID-validated, survives st=clear), persisted per terminal in
    workspace.json, resumed on restore behind the opt-in
    auto_resume_agent_sessions setting (claude --resume <id>).
  • Per-harness resume/transcript logic behind a gpui-free
    okena_core::agent_harness registry keyed by agent id (desktop + headless);
    Claude Code + Codex impls in the okena-ext-* crates — adding a harness is additive.
  • Auto-resume requires a session backend (the terminal_id the session is keyed
    by only survives a restart with tmux/dtach/screen).

Hardening (internal review pass)

  • Bound custom/labels (mirror the OSC 99 caps) against unbounded in-band input
    amplified to every remote client; base64 decode off-lock; empty msg → default body.
  • Headless PTY loop now drains the remote_dirty edge so agent status pushes to
    remote/mobile clients (was desktop-only).
  • reveal_terminal no longer clears individual-zoom on a same-project click;
    corrected the OKENA_TTY tmux-reattach docs.

Docs: docs/agent-status.md, crates/okena-terminal/CLAUDE.md.

Other terminal-protocol commits already on the branch

OSC 1337 cwd · OSC 9;4 progress + tab progress (+ percent) · OSC 133 jump-to-failed ·
OSC 52 clipboard read (opt-in) · CSI 14t pixel size · kitty keyboard protocol
(level 1) + Esc/Tab routing.

Testing

  • cargo build (workspace) ✅; tests green for the touched crates (okena-core,
    okena-terminal, okena-workspace, okena-state, okena-ext-claude, okena-views-terminal).
  • New tests: OSC 9001 parsing incl. hostile-input bounds & session capture,
    remote_dirty edges, reveal_terminal regressions, UUID validation, the Claude
    harness command, parse_labels_json.
  • ⚠️ Not runtime-verified: resume-on-restart (desktop + tmux + claude) is
    build-/unit-tested only, not exercised live.

Follow-ups (not in this PR)

  • Codex: confirm the exact codex resume invocation (harness stub returns None)
    • Codex session-capture glue.
  • Transcript stats UI (UC1) — okena-ext-codex already parses its JSONL.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KbKbY5iGGgXSnpHz2V5aSr

@matej21
matej21 force-pushed the feat/agent-status branch 2 times, most recently from 4ba528b to d9e5a15 Compare August 4, 2026 13:20
matej21 and others added 16 commits August 5, 2026 17:17
… & resume

Report what an AI coding agent (Claude Code, Codex, …) is doing in each pane
via a push-based, in-band OSC 9001 protocol, and remember/resume its session
across restarts. The model is open: a small fixed lifecycle drives colour/sort/
notifications while a free-form message + labels carry whatever the agent wants.

Display & protocol:
- OSC 9001 (st/msg/lbl) parsed in the terminal sidecar into a canonical
  AgentStatus; surfaced as a per-tab indicator, a cross-project sidebar AGENTS
  section (click to jump), a per-project field in the remote API, and a desktop
  notification on blocked/done. Runtime-only, never persisted.
- reveal_terminal navigates to a hidden pane (retargets zoom/fullscreen) behind
  agent-row / notification / remote-focus clicks.
- Bundled Claude Code plugin (integrations/claude-code) maps lifecycle hooks to
  states; OKENA_TTY exports the pane's slave pts so hooks without a controlling
  terminal can emit the OSC.

Session capture, persistence & resume (harness-extensible):
- Reserved lbl keys agent/session_id/transcript_path are captured into a sticky
  per-pane AgentSession (UUID-validated, survives st=clear), persisted per
  terminal in workspace.json, and resumed on restore behind the opt-in
  auto_resume_agent_sessions setting (e.g. `claude --resume <id>`).
- Per-harness resume/transcript logic lives behind a gpui-free
  okena_core::agent_harness registry keyed by agent id (works desktop + headless);
  Claude Code + Codex impls in the okena-ext-* crates. Adding a harness is additive.
- Auto-resume requires a session backend (the terminal_id the session is keyed by
  only survives a restart with tmux/dtach/screen).

Hardening:
- Bound OSC 9001 custom/labels to mirror the OSC 99 caps (a hostile pane could
  otherwise pin unbounded in-band memory, amplified to every remote client);
  decode off-lock; empty msg falls back to the default notification body.
- Headless PTY loop now drains the remote_dirty edge so agent status pushes to
  remote/mobile clients (was desktop-only).
- reveal_terminal no longer clears individual-zoom on a same-project click
  (regressed sidebar/cursor clicks); corrected the OKENA_TTY tmux-reattach docs.

Docs: docs/agent-status.md, crates/okena-terminal/CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbKbY5iGGgXSnpHz2V5aSr
Three defects in the OSC 9001 capture path, all in how agent-supplied
fields are bounded:

- The reserved `agent` / `session_id` / `transcript_path` keys were read
  from the map *after* `new_clamped` kept only the lowest MAX_LABELS
  entries, so 32 lexicographically earlier labels hid a valid session and
  the 1 KiB value cap could silently cut a transcript path. Extraction
  moves to `AgentSession::from_labels`, fed the raw decoded map.
- Re-reporting the same session without `transcript_path` replaced the
  record wholesale and flipped a known path back to None. Same-session
  reports now merge; a different session still replaces.
- `String::truncate` only moves `len`, so a decoded multi-megabyte `msg=`
  kept its full capacity alive on the Terminal for as long as the status
  was held — the documented cap bounded length, not memory.

Also swaps `libc::ptsname` for portable-pty's cached `tty_name` (resolved
via reentrant `ttyname_r`): terminals are created after the `instances`
lock is dropped, so two concurrent creations could read each other's path
out of ptsname's static buffer and export the wrong $OKENA_TTY.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
Auto-resume could never fire. The only caller of `resume_command` was
TerminalPane, which reads the GUI's workspace mirror — and since the
full-headless merge the GUI is always a thin client whose mirror starts
empty (`ApiProject` carries no `agent_sessions`, `remote_apply` seeds an
empty map). The feature predates that merge and was not re-pointed at the
daemon when it landed.

It would not have worked even wired up. Sessions are keyed by terminal id,
but a restore without a session backend clears exactly those ids, orphaning
every session; with a backend the ids survive but so does the agent, so
resuming is wrong. Both ends were broken.

The daemon now owns it end to end:

- On load, `validate_workspace_data` re-keys each surviving session from its
  about-to-be-dropped terminal id onto the pane's layout path (stable across
  that one load) in a transient `pending_agent_resumes` map.
- `spawn_uninitialized_terminals` consumes that entry as it assigns the new
  terminal id, runs the resume as the pane's *startup command* (chained after
  `on_create`, so the pane still lands on an interactive shell), and re-keys
  the session onto the new id. Consuming makes it exactly-once, and hanging it
  off the spawn path means a pane re-attaching to a live tmux session is never
  touched.
- The client-side path is gone: no argv joined into a shell line, no timed
  write into an already-attached PTY. Harness argv must now be shell-neutral
  (`resume_command_line` refuses anything else) rather than quoted per dialect.

Sessions also track the terminal now: carried across both cross-project move
paths, kept through a soft close's undo window and dropped when it finalizes,
dropped on hard close and shell switch, and pruned on load when the pane is
gone or the stored id fails the UUID check that guards persisted state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
The daemon fills `terminal_agent_status` on every project, but no client
read it. Clients learn agent status by parsing OSC 9001 out of the live
byte stream, and a snapshot replays a *rendered grid* — the OSC that set
the current status was consumed long before. A desktop client that started
or reconnected mid-run therefore showed no agent indicator until the agent
happened to report again, and could keep showing a status the daemon had
already cleared.

`snapshot_agent_statuses` projects the snapshot onto client-side (prefixed)
terminal ids, emitting an entry for *every* pane in the layout so a cleared
status clears rather than lingers. The window view applies it to the
registry via `Terminal::hydrate_agent_status`, which deliberately skips the
notification queue (the daemon already notified on the transition; firing
again per reconnect would spam) and the remote-dirty edge.

Mobile FFI now projects the same status, which it previously dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
… tty

$OKENA_TTY is captured once at spawn, so it names a pty that is already
wrong after the pane reattaches to a persistent dtach/tmux session — the
status either vanishes or, worse, lands in whichever pane has since been
given that path and drives the wrong agent's indicator.

Two changes close both halves:

- The sender now prefers /dev/tty, which always resolves to the pane's
  current pty, and falls back to $OKENA_TTY only for hooks that run with no
  controlling terminal (the case it was added for). The probe lives in a
  subshell on purpose: POSIX makes a redirection error on a special built-in
  exit the shell, so a bare `: >/dev/tty` would abort the script in exactly
  the no-tty case — and as a PreToolUse hook, a non-zero exit blocks the
  tool call.
- The pane exports OKENA_TERMINAL_ID and the sequence carries it as `tid=`.
  A pane drops a status addressed elsewhere, before the session-capture
  step, so a stray writer cannot plant a session that would later be
  persisted and resumed. `tid` is optional, so existing senders still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
The sidebar AGENTS list is cross-project by design, so a row can point at a
project the current window renders nowhere — one in its hidden set or
outside its folder filter. Clicking only moved focus, which silently landed
on an invisible pane.

`focus_terminal_by_id` now takes the calling window and decides revealing
for itself: in overview, a target the window isn't showing gets zoomed, so
`compute_visible_projects`' focus override surfaces it past both the hidden
set and the filter. Neither is mutated, so zooming back out returns the
user to exactly the view they had.

This also fixes the notification jump, which had the rule open-coded: it
zoomed to the target project *before* delegating, and changing the focused
project drops the fullscreen context — so jumping out of a fullscreened
pane dumped the user into the overview instead of following them into the
target. With the decision inside, the fullscreen branch retargets in place
and the jump stays fullscreen.

All six jump call sites (agent rows, sidebar clicks, cursor navigation,
notifications) now share the one operation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
…es disk

Five review findings on the OSC 9001 entry path.

`tid=` never matched. The pane exports `$OKENA_TERMINAL_ID` as the daemon's
raw terminal id, but every client-side `Terminal` — including the desktop
app's own panes, which it consumes through the local daemon — is keyed
`remote:{connection}:{raw}`. The literal comparison was therefore always
false, so the bundled Claude Code plugin's status was dropped on every
client and the blocked/done desktop notification never fired. Match the
unprefixed suffix on a `:` boundary. The existing tests passed only because
they used an unprefixed `Terminal::new("t")`.

`AgentSession.agent` and `.transcript_path` had no bound at all. They are
read pre-clamp (so a label flood cannot hide a session), which left them the
only agent-status fields with no cap — and the only ones persisted. A pane
could put megabytes of chosen bytes into `workspace.json` and re-fire the
dirty edge to rewrite it on every autosave. Bound and reject rather than
truncate, and re-check in `is_valid()` so an already-poisoned file is pruned
on load. `transcript_path` is additionally required to be absolute and free
of `..`, since it is what a harness's transcript parser will open.

Session capture sat behind the lifecycle check, so `st=clear` returned
first — and a harness maps session start/end onto `clear`, i.e. exactly the
two events carrying the identity. Capture now runs for any well-formed
sequence, before `st` is interpreted.

The reserved keys were left in the display labels, shipping `session_id` and
the absolute local `transcript_path` to every paired remote/mobile client on
every `GET /v1/state`. `RESERVED_LABEL_KEYS` existed for this and had no
callers; strip them after capture.

Tests also cover the chunked, duplicate-key, and valid-base64-of-non-UTF-8
edges the parser has to survive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
The standalone `okena-daemon` binary never called `agent_harness::init`, and
structurally could not: both impls lived in `okena-ext-claude` /
`okena-ext-codex`, which depend on gpui, while the daemon is CI-gated
GPUI-free. So `for_agent()` returned `None` there and auto-resume was a
silent no-op in the deployment that owns restore — the registry's own doc
claim that it is "kept gpui-free so they work in the desktop app AND in
headless/remote" was unachievable as structured.

A harness is pure data (an id and a fixed argv), so move both into a new
gpui-free `okena-agent-harnesses` crate and install it from every binary
that can reach a restore path. `cargo tree -p okena-daemon` stays clean.

Also make the failures visible instead of silent: `init` warns when a second
install is ignored, and `resume_command_line` warns on a `for_agent` miss
rather than bailing at a bare `?` between two branches that both log.

The release build compiled `-p okena -p okena-daemon` in one invocation.
Cargo unifies features across every package selected in one invocation, so
`okena` requiring `okena-workspace/gpui` and `okena-app-core/gpui` enabled
those for the daemon's copy too — linking GPUI into the binary the gate
exists to keep clean, where `cargo tree -p okena-daemon` alone would never
see it. Split into two invocations in CI and in bundle-macos.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
Three ways the shipped plugin was silently dead or wrong.

Device preference was backwards for tmux/screen. `/dev/tty` was preferred on
the premise that it always names the pane's current pty. That holds for
dtach, but under `session_backend = tmux` the pane process *is* tmux, so a
hook's controlling terminal is tmux's pty — and tmux forwards only a fixed
allowlist of OSC numbers, which 9001 is not on. A hook inherits a
controlling terminal, so the probe succeeded and the one device that works
($OKENA_TTY, Okena's own slave, which bypasses the nested pty) was never
used. Prefer $OKENA_TTY when it is set and writable. The stale-path risk
that motivated the other order is already covered by `tid=`.

Both vars were applied with `cmd.env()` on the spawned command, which under
a session backend is `sh -c "tmux new-session …"` — so they landed on tmux,
not the pane's shell, and with a server already running the shell inherited
an environment that predates Okena. Route them through `launch_environment`,
which is what gets rendered as tmux `-e KEY=VAL`; the existing loop still
applies them directly on the no-backend path.

`json_str`'s regex is greedy, so it took the *last* match on the line — and
Claude Code's PreToolUse/PostToolUse payloads embed `tool_input` as nested
JSON whose keys are not string-escaped. Any tool taking a `session_id` or
`transcript_path` argument shadowed the real field, and the forged UUID
became the pane's persisted session and the argument to `claude --resume`.
Mine top-level keys only: `jq` when present, otherwise collapse nested
objects/arrays first. Verified against a hostile payload on sh, dash and
bash, with and without jq.

Also: validate `$1` against the known states, so a caller passing through a
less-trusted value can't append `;lbl=` or `;tid=` (and a typo gets a log
line instead of `write=ok`); suppress sed/base64 stderr like every other
command in the script already does, since a PreToolUse hook writing to
stderr surfaces as hook noise; and log why the session is skipped when
`session_id` was found but `OKENA_AGENT` is unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
`agent_sessions` is a terminal-id-keyed sibling of `terminal_names` /
`hidden_terminals`, and the terminal id is the one thing every lifecycle
event rewrites. It was maintained at the move, close and soft-close sites
and nowhere else:

- The project-runtime quiesce carried `terminal_name` / `hidden` onto the
  pane's new id but not the session, so a `session_backend` switch or a
  project-directory move silently destroyed every pane's agent identity and
  left dead-keyed entries behind. Carry it through `ProjectRuntimeSlot`.
- Hook teardown (both paths), `swap_hook_terminal_id`, and the failed-hook
  cleanup in the replacement path all maintained the sibling maps and skipped
  this one.
- `cleanup_orphaned_metadata` pruned by a list derived from `terminal_names`,
  which only ever names panes the user renamed. A pane carrying just a
  session — almost every pane — was never pruned, so a multi-pane close (a
  tab group) left orphans a later pane could inherit and resume. Prune
  sessions against the layout instead, exempting panes on their way out
  through a deliberate close.
- The cross-project move's source prune treated a soft-closed pane as
  orphaned: moving any *other* pane during the grace window deleted its
  session, and undo restored the pane without it.

`pending_agent_resumes` was queued by `validate_workspace_data` but only
consumed by `spawn_uninitialized_terminals`. LoadSession and ImportWorkspace
go through `prepare_layout_terminals`, which mints fresh ids and installed
the map as live state — so every pane lost its session, and the orphaned
entries stayed where a later split landing on the same path would inherit
one and resume someone else's conversation. Re-key onto the new id there and
clear what is left.

`persist_agent_sessions` consumed the dirty edge before it knew the terminal
was addressable. `find_project_for_terminal` searches layout trees only, so
hook terminals, service terminals and panes inside a soft-close window all
missed — and the session was gone for good, since agents re-send the
identical identity and nothing re-arms the edge. Peek instead, resolve
through a broader lookup, and consume only what landed (and only while the
stored session is still the one written).

Also: route snapshot hydration through `new_clamped` so the caps are an
invariant of the type rather than of the parser; correct the comment
claiming the daemon raises the notification (it drains and discards the
queue — the gap is real and now stated); and register both new `ProjectData`
fields in the CAT-2 audit ignore list with their rationale, so the gate
reports zero instead of two permanent untriaged hits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
`focus_terminal_by_id`'s doc claims it is "the one navigation operation
behind every 'jump to this terminal'", including remote focus requests. Two
call sites were not converted:

- `apply_remote_snapshot`'s focus targets — the "a phone created a terminal,
  focus it" flow — went through `set_focused_terminal`, which by its own doc
  is the click-an-already-visible-pane primitive and cannot reach an
  off-screen project. `RemoteFocusTarget` now carries the terminal id.
- The project switcher's Tab action picked its window on the hidden set
  alone (ignoring the folder filter and zoom/fullscreen) and focused by
  path. Zoomed into project A, switching to B moved focus while the zoom
  stayed on A; under fullscreen it overwrote the *fullscreened* terminal's
  layout_path with a path from another project. It also gave up entirely
  when no window had the project unhidden — now it falls back to the origin
  window and lets the reveal zoom it in.

`reveal_terminal`'s overview→zoom branch assigned `focused_project_id`
directly instead of going through the save/restore, so the None→Some
transition never captured `pre_zoom_focus`. Jump to a hidden project from
overview, zoom back out, and focus was stranded on the now-invisible project:
no focus ring, `focused_terminal_state()` lying, keystrokes going elsewhere.

Other UI fixes:

- The per-pane agent indicator only rendered inside a tab bar, so the common
  "one project, one pane, running an agent" case — a bare LayoutNode::Terminal
  — showed nothing. Tint the pane border like the bell does, excluding idle.
- Agent-supplied `custom` text was interpolated whole into two tooltips.
  4 KiB bounds memory, not layout, and the tooltip is the one surface with no
  ellipsis. Flatten and clip via a shared `display_snippet`.
- Sidebar Enter on a hook row was a silent no-op: hook terminals live outside
  the layout tree, so `focus_terminal_by_id`'s guard never matched. Mirror
  the mouse path and open the overlay.
- The mobile FFI sent `AgentLifecycle::label()` as the protocol token, which
  that method's own doc forbids. Add `token()`, use it at the boundary, and
  pin the wire values in a test so a display-label rename can't break the RN
  contract silently.
- Bound `pending_notifications`. The consumer spawns a thread per bubble and
  blocks it until dismissed; the queue had no cap, so ~22 bytes of pane
  output bought a thread. Cap at 32, drop-oldest, on the sink rather than on
  each emitter.
- Extract the AGENTS attention ordering into a testable free function — it
  is the contract docs/agent-status.md states, and nothing checked it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
…ting

The protocol doc was accurate on mechanically-checkable details but wrong or
silent on several things a user would act on:

- The manual (no-plugin) hook recipe omitted `OKENA_AGENT`, which the capture
  hard-requires. Following it gave you the indicator and silently no session,
  no persistence and no resume — while the same document's entire "Session
  resume" section describes behaviour you would never get.
- `auto_resume_agent_sessions` was named but never located. It was absent
  from docs/configuration.md — the exhaustive settings reference, which
  documents its sibling `session_backend` — and had no UI, so the only way to
  enable a shipped opt-in feature was to read Rust and hand-edit JSON. Added
  to both, plus a toggle next to the session backend.
- The device guidance said to prefer `/dev/tty`, which is wrong under
  tmux/screen; it also contradicted itself further down.
- `enabledPlugins` was presented as an *alternative* to adding the
  marketplace, which leaves `@okena` unresolvable.
- `labels` were documented as "rendered verbatim". Nothing renders them.
- Codex read as supported; it is a registration-only stub with no capture glue.
- No platform caveat, though the whole path is `#!/bin/sh` + `/dev/tty` and
  `$OKENA_TTY` is Unix-only — a Windows user gets a silent no-op and a
  Debugging section that points at the wrong cause.
- Nothing said that a stale status never expires, that the resume record is
  "the last agent session this pane ever ran" rather than "what was running
  at shutdown", or that a notification is only raised by the client that
  parsed the transition live.
- `$OKENA_TTY` was framed purely as a correctness fallback; documented the
  reopen-by-recycled-path risk it also carries.

Also: README's docs index didn't list agent-status.md at all; docs/hooks.md
still scoped `OKENA_TERMINAL_ID` to `terminal.on_close` and never mentioned
`OKENA_TTY`, though both are now exported into every pane; and docs/remote.md
had no `terminal_agent_status` despite being the page agent-status.md points
readers at for it.

Tests: the three `forget_agent_session` call sites (hard close, close-many,
shell switch) had no coverage — the file had no test module at all, and the
docs promise those drops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011st5wPRrrAUgA8EQYrGWwY
The border was meant as a fallback for panes with no tab bar, but
`show_border` never checked whether one exists — and `render_tabs` draws
its active tab through a child LayoutContainer that ends up in the same
TerminalPane. So a tabbed pane showed the lifecycle twice: recolored tab
icon plus a full border around the content.

Worse, the border read as an alarm that could not be dismissed. A status
is sticky — only the next status or `st=clear` removes it, and unlike the
bell no activity clears it — so a finished turn left the whole pane
outlined until the next prompt.

Drop it. The tab indicator and the sidebar AGENTS list carry the
lifecycle; the border goes back to focus, bell, OSC 9/777 notifications
and waiting-for-input.

A bare `LayoutNode::Terminal` now shows its agent only in AGENTS and in
the notification. That is the tradeoff, and the docs say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011juTKMupZDjwamSzbNhp9k
…st one

Restart Okena with a session backend and every running agent goes quiet:
the pane reattaches, the agent keeps working, and nothing ever reaches
AGENTS again until the pane itself is recreated.

`$OKENA_TTY` is captured into a pane's environment when the pane is first
launched and can never be refreshed afterwards. Under dtach/tmux/screen
the shell — and the agent under it — outlives Okena, so after a restart
the variable still names the previous run's pty, which Linux has since
handed to a different pane. The hook's write then succeeds against the
wrong pane, where `tid=` correctly drops it: silent loss on both ends,
logged as `write=ok`. Measured on a live dtach pane whose `OKENA_TTY` was
`/dev/pts/9` while `/dev/pts/9` belonged to another pane's session.

`/dev/tty` is no fallback here. Claude Code runs its hooks in a new
session, so they have no controlling terminal at all — and under tmux or
screen it would name the nested pty, which forwards only an OSC allowlist
that 9001 is not on.

What does survive a restart is the pane's terminal id (ids are only
cleared when the backend has no persistence). So key a file by it and
rewrite the contents on every spawn: `$OKENA_TTY_FILE` is a path stable
enough to capture into the environment once, naming a device that is
always current. Published in `create_terminal_with_id`, revoked in
`enqueue_session_kill`, and swept at `PtyManager::new` — the sweep only
removes pointers whose device is already gone, so a throwaway test
manager cannot disturb a live Okena.

The script now prefers that device, falls back to `$OKENA_TTY` and then
`/dev/tty`, and records which one it used as `src=` in the debug log.

Sessions created before this have no such variable and keep falling back
to the frozen `$OKENA_TTY`; recreating the pane picks the pointer up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vNWNgioH8JeerfHgfN31y
Fallout from a `cargo fmt --all` run while fixing the agent-status tty
pointer: CI never checks formatting, so a few commits on this branch
landed unformatted.

Line wrapping and trailing commas only — the working tree is tokenwise
identical to HEAD for every file here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vNWNgioH8JeerfHgfN31y
@matej21
matej21 force-pushed the feat/agent-status branch from 8a1f60c to 770b237 Compare August 5, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant