feat: agent status (OSC 9001) + terminal in-band protocol features - #156
Open
matej21 wants to merge 16 commits into
Open
feat: agent status (OSC 9001) + terminal in-band protocol features#156matej21 wants to merge 16 commits into
matej21 wants to merge 16 commits into
Conversation
matej21
force-pushed
the
feat/agent-status
branch
2 times, most recently
from
August 4, 2026 13:20
4ba528b to
d9e5a15
Compare
… & 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
force-pushed
the
feat/agent-status
branch
from
August 5, 2026 15:21
8a1f60c to
770b237
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
to jump), a per-project
terminal_agent_statusfield in the remote API, and adesktop notification on
blocked/done. Runtime-only.integrations/claude-code/) maps lifecyclehooks → states;
OKENA_TTYlets hooks without a controlling terminal emit the OSC.Session capture, persistence & resume (harness-extensible)
lblkeysagent/session_id/transcript_path→ a sticky per-paneAgentSession(UUID-validated, survivesst=clear), persisted per terminal inworkspace.json, resumed on restore behind the opt-inauto_resume_agent_sessionssetting (claude --resume <id>).okena_core::agent_harnessregistry keyed byagentid (desktop + headless);Claude Code + Codex impls in the
okena-ext-*crates — adding a harness is additive.terminal_idthe session is keyedby only survives a restart with tmux/dtach/screen).
Hardening (internal review pass)
custom/labels(mirror the OSC 99 caps) against unbounded in-band inputamplified to every remote client; base64 decode off-lock; empty
msg→ default body.remote_dirtyedge so agent status pushes toremote/mobile clients (was desktop-only).
reveal_terminalno longer clears individual-zoom on a same-project click;corrected the
OKENA_TTYtmux-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).
remote_dirtyedges,reveal_terminalregressions, UUID validation, the Claudeharness command,
parse_labels_json.claude) isbuild-/unit-tested only, not exercised live.
Follow-ups (not in this PR)
codexresume invocation (harness stub returnsNone)okena-ext-codexalready parses its JSONL.🤖 Generated with Claude Code
https://claude.ai/code/session_01KbKbY5iGGgXSnpHz2V5aSr