# docs/07-agents.md · lucenx/signaltty

[View on GitCafe](https://git.cafe/lucenx/signaltty/blob/refs%2Fheads%2Ffix%2Fplugin-hook-timeout-max-v2/docs/07-agents.md)

Repository: [lucenx/signaltty](https://git.cafe/lucenx/signaltty)

Visibility: public

Requested revision: refs/heads/fix/plugin-hook-timeout-max-v2

Commit: 4d9a5fb63e09c2e5a10678b9b3b28550c44c9933

Blob: faf415903eb8efa99d026075649354232c9c4263

Size: 13782 bytes

[Immutable source](https://git.cafe/lucenx/signaltty/blob/4d9a5fb63e09c2e5a10678b9b3b28550c44c9933/docs/07-agents.md?format=markdown)

````
# 07 — Agent Integration Strategy

Layered detection, highest-signal first. Explicit signals always
override heuristics. Unknown apps remain fully working terminals.

## Priority stack

1. **Native integration** — agent exposes state over a local API the
   adapter can poll/subscribe (rare today; e.g. OpenCode `serve` HTTP,
   Codex `app-server`). Best-effort where officially documented.
2. **Agent hooks / semantic events** — the primary mechanism for
   Phase 2. Small shims installed into each agent's hook system call
   `signaltty hook-event --agent <kind> --event <name> --pane $SIGNALTTY_PANE`
   (pane id injected via env at spawn) with the hook JSON on stdin.
3. **Standard terminal notifications** — OSC 9 (iTerm2/ConEmu),
   OSC 99 (Kitty), OSC 777 (rxvt/Ghostty/WezTerm) scanned from the PTY
   byte stream server-side. Any CLI/script can emit them; zero config.
4. **Foreground process info** — `/proc`: argv, cwd, child tree, CPU.
   Identifies *which* agent runs (`codex`, `claude`, `opencode`,
   `cursor-agent`) and whether it is forked/busy.
5. **Terminal state** — title (`OSC 0/1/2`), BEL, cursor, alt-screen.
   Cheap, no parsing of natural language.
6. **Output heuristics** — last resort only (spinners, prompt shapes).
   Never the architecture's foundation; gated behind "no semantic
   signal for N seconds".

## Adapter interface

```rust
trait AgentAdapter: Send + Sync {
    /// Does this adapter own the pane's foreground process?
    fn identify(&self, proc: &ProcessInfo) -> bool;
    /// Map a semantic event / poll snapshot to lifecycle + attention.
    fn lifecycle_state(&self, ev: &AdapterEvent) -> LifecycleDecision;
    /// Native session id, if the agent reports one.
    fn session_identity(&self, ev: &AdapterEvent) -> Option<String>;
    /// Map hook payload / OSC / exit to a notification (or none).
    fn notification_event(&self, ev: &AdapterEvent) -> Option<NotificationDraft>;
    /// Official resume argv for a persisted session id, if supported.
    fn resume_capability(&self, session_id: &str) -> Option<ResumeCommand>;
    /// Static metadata: kind, display name, hook install recipe.
    fn metadata(&self) -> AdapterMetadata;
}
```

Adapters: `CodexAdapter`, `ClaudeCodeAdapter`, `OpenCodeAdapter`,
`CursorAdapter`, `GenericTerminalAdapter` (process + title + BEL only).

## Per-agent integration (verified Sept 2026, local CLIs)

### Codex CLI (`codex`)

- Resume: `codex resume [SESSION_ID]`, `codex resume --last`;
  non-interactive `codex exec resume <id|--last>`; `codex queue`,
  `codex fork`, `codex archive`. Session ids are UUIDs/names.
- Hooks: `~/.codex/hooks.json` / `[hooks]` in `config.toml`; events
  include `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
  `PermissionRequest`, `PostToolUse`, `Stop`, `SessionEnd`,
  `SubagentStop`. Payload on stdin carries `session_id`,
  `transcript_path`, `cwd`, `hook_event_name`, `turn_id`.
  No `Notification` event — use `notify = ["signaltty","hook-event",…]`
  (fires on `agent-turn-complete`) plus `tui.notifications` → BEL.
- Session env: `CODEX_THREAD_ID` equals Stop payload `session_id`.
- Shim: `notify` entry + hook commands calling `signaltty hook-event
  --agent codex`. Lifecycle: `UserPromptSubmit`→working,
  `PermissionRequest`→blocked+permission_required, `Stop`→done+unread.
- Verified against codex 0.157.1 (Sept 2026): hooks are trust-gated —
  new/changed entries sit at "review required" until the user picks
  "Trust all and continue" in an interactive session (hash recorded
  under `[hooks.state]` in config.toml); `codex exec` cannot grant
  trust, but `codex exec --dangerously-bypass-hook-trust` fires hooks
  (SessionStart/UserPromptSubmit/Stop/SessionEnd all observed). Hook
  timeouts clamp to 3s — our installer uses 3. The `Stop` hook
  REQUIRES stdout to be empty or JSON on exit 0 (plain text fails the
  hook), so `signaltty hook-event` prints human feedback to stderr and
  keeps stdout silent unless `--json`.

- Codex 0.159.1 can reuse a detached shared daemon that retains a previous
  terminal's `SIGNALTTY_PANE` / socket. Hooks run with the daemon environment,
  causing `hook exited with code 1` (`NO_SUCH_PANE`) or updating another pane.
  Direct managed local interactive launches, split and resume use native
  `--no-daemon` when supported (bounded version probe); original stored argv,
  config/auth/trust and hook commands remain unchanged. Unsupported clients or
  explicit remote sessions retain their command with an integration notice.
  Exec/utility commands are unchanged. See [ADR-0014](adr/0014-codex-pane-runtime.md).
  When typing Codex in an ordinary shell, use `codex --no-daemon`. To recover an
  existing session, exit the TUI and run `codex --no-daemon resume --last` in the
  same pane. Already-running clients keep their backend until restarted;
  Signaltty does not stop the shared daemon or bypass hook trust.

### Claude Code (`claude`)

- Resume: `claude --resume [id]`, `-c/--continue`, `--session-id <uuid>`
  for pinned new sessions; `--settings <file>` for hook injection;
  `-p/--print` non-interactive; `claude attach/logs/stop/rm/agents`.
- Hooks: settings JSON (`~/.claude/settings.json`, project/local,
  `--settings`); events `SessionStart`, `UserPromptSubmit`,
  `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`
  (`idle_prompt`), `PreCompact`. Payload `session_id` equals
  `$CLAUDE_CODE_SESSION_ID`; survives `-p --resume`.
- Shim: settings snippet installed by `signaltty integration install
  claude` (user-scoped, non-destructive merge). `Notification` →
  attention directly; `Stop` → done+unread.

### OpenCode (`opencode`)

- Resume: `opencode --session <id>` / `-s`, `--continue` / `-c`;
  headless `opencode run --session <id>`; `opencode serve` + HTTP API;
  `opencode attach`, `opencode session`, `opencode export`.
- Hooks: JS/TS plugin system (`~/.config/opencode/plugins`,
  25+ events): `session.created/updated/idle/status`,
  `command.execute.before`, `event`, `config`. Plugins receive
  `properties.sessionID` and can shell out.
- Shim: a small plugin file calling `signaltty hook-event --agent
  opencode`. `session.status busy/idle` → working/done; idle →
  done+unread. `serve` API is the future native-integration path.

### Cursor Agent (`cursor-agent`)

- Resume: `--resume [chatId]` (picker when bare), `--continue`;
  `--print --output-format json|stream-json` headless; `--model`,
  `--mode plan|ask`.
- Hooks: `~/.cursor/hooks.json`; `sessionStart` includes a stable
  `session_id` mappable to the CLI pid (must reject Desktop-origin
  events via process ancestry check).
- Shim: `hooks.json` entry + reporter script. Screen/title fallback
  aligned with Codex (session-only pattern, no output regex).

## Session identity & resume (see also [09](09-persistence.md))

- Identity arrives via hook payload/env only — never scraped from
  terminal text while a better channel exists. Exception: resumed
  sessions may fire no `SessionStart` (observed: `muse resume` emits
  only prompt/Stop); the server therefore also accepts
  `signaltty report-session --pane <id> --session <sid>` and keeps the
  last known id across respawns in the same pane.
- `resume_capability()` returns the official argv (table above). The
  server persists it but never auto-runs it: restore offers
  one-click/keypress resume per pane (LIVE/RESTORED/RESUMABLE/EXITED).

## Claude permission-channel probe

A live probe on 2026-10-02 verified Claude `PermissionRequest` Once/Deny
verdicts, including two simultaneous signaltty panes. It also records terminal
answers, timeout fallback, and ignored late verdicts. The experiment ran against
`eaf8076` with a response file standing in for the GUI choice. It does not
independently verify the subsequently implemented native response bridge.
See [probe results](../specs/003-inline-approvals/probe-results.md) and the
[repeatable experiment](../specs/003-inline-approvals/quickstart.md).

## Hook attribution (pane routing)

`hook-event` resolves its pane in two steps: explicit `pane_id`
(shims read `$SIGNALTTY_PANE`, injected by the server at spawn), else
process-ancestry fallback — the CLI sends its pid as `client_pid` and
the server walks `/proc` parents to the deepest live pane child. The
fallback survives sandboxed agents that strip hook env, and safely
ignores foreign hooks (Cursor Desktop shares `hooks.json` but its
processes are never our descendants → accepted, classified nothing).

## Detection overlays: manifests (data, not code)

`$XDG_CONFIG_HOME/signaltty/agents/*.toml` (`SIGNALTTY_AGENTS_DIR`
override, `--agents-dir` on the server) extend one existing adapter kind
without recompiling — wrapper binaries, post-churn hook maps, custom
session keys. Loaded once at startup; malformed files are logged and
skipped, never fatal. `signaltty integration status` lists them.

```toml
[agent]
kind = "codex"              # required: an existing AgentKind
# display_name = "…"        # optional override
# binaries = ["codex-wrap"] # merged with the builtin list

[session]
# key = "thread_id"         # payload session-key override
# resume = ["codex", "resume", "{session_id}"]

[lifecycle.SomeFutureHook]    # any hook; unmapped hooks fall through
# lifecycle = "working"
# attention = "unread"
# message = "…"
```

Each field falls through to the builtin adapter independently when
absent. `kind` must stay inside the typed taxonomy — a genuinely new
agent still needs a Rust adapter. See ADR-0009.

## Live process refresh (layer 4)

Spawn argv is only the first word. Every 10s the server re-reads each
live pane's deepest shell-transparent descendant (`/proc`: basename +
cwd): generic panes promote to the detected kind (never demote), and
`pane.cwd` follows `/proc/<pid>/cwd`. One `pane.updated` per changed
pane; silence otherwise (no event spam, no snapshot churn).

## Fallback behavior

No hooks installed, unknown binary, or adapter disabled → pane works
as a plain terminal with `GenericTerminalAdapter`: title tracking,
BEL → `unread`, exit code → `done`/`failed`, OSC notifications still
honored. Zero-configuration agents (raw `claude` with no hooks) still
get OSC + title + exit semantics.

## Automatic configuration (ADR-0013)

Starting a supported agent directly from the New Workspace dialog or `pane.spawn`,
`pane.split`, `pane.resume` prepares its hook integration before the process starts.
Preparation uses the actual executable basename, not `agent_hint` or detection
manifests. The ambiguous `agent` command is generic by default: it may be Grok;
known Cursor aliases can still be declared explicitly in detection manifests.

Only the selected provider is configured. Existing shells and agents already
running are not reconfigured; for agents typed inside a shell use
`signaltty integration install <provider>` first, or start a new workspace with
that agent selected. Restarting an agent may be needed after configuration.

Shared installation merges owned command hooks, preserves foreign hooks/keys,
refuses malformed files and unrelated OpenCode plugins, refreshes stale paths,
and uses bounded advisory locking and atomic replacement. Uninstall removes
only managed commands, including inside mixed groups. Claude `CLAUDE_CONFIG_DIR`,
Codex `CODEX_HOME`, OpenCode `OPENCODE_CONFIG_DIR` and the default XDG OpenCode
location are honored. Explicit CLI `--home` isolates provider home overrides;
OpenCode still honors `XDG_CONFIG_HOME`.

A setup failure keeps the terminal usable and returns `integration.status=error`
with a notice shown in the GUI/CLI. Explicit Claude `--bare` or setting sources
excluding `user`, and OpenCode `--pure`, return `disabled` without installing.
Installation never enables a user's disabled hooks. `configured`/CLI `installed`
mean files contain managed hooks, not that events have fired. For newly written
Codex hooks the notice asks to review/trust them in `/hooks`; Signaltty does not
alter `[hooks.state]`, grant trust or inject trust-bypass flags.

## Native permission replies (ADR-0015)

Managed Claude and Codex `PermissionRequest` hooks use a dedicated waiting
reporter. It converts the provider's `session_id`, `tool_name` and `tool_input`
into a structured prompt with **Allow once** and **Deny**. The GUI or CLI answer
returns through the live reporter as `hookSpecificOutput.decision.behavior`,
not as terminal keystrokes. Reporting-only requests have no native answer route
and render read-only.

The permission reporter has a 125-second provider timeout and a bounded
120-second server wait. No answer, cancellation, supersession, disconnect or
server restart produces no permission verdict, so the provider retains its own
permission flow. Answers validate the request's current pane, session and
deadline. The transport is transient and cannot be restored from an audit log.
Ordinary status reporters retain their short timeouts and silent stdout.

Current Codex 0.159.1 accepts the same narrow allow/deny output as Claude; its
tagged hook engine honors configured timeouts without the earlier three-second
limit. It rejects session permission updates, modified tool input and interrupt
requests, so this integration offers no permanent **Always** policy. Hook trust
remains Codex's own interactive review. The installer preserves foreign hooks
and disabled settings as before.

Sources: [Claude PermissionRequest reference](https://code.claude.com/docs/en/hooks#permissionrequest-decision-control),
[Codex 0.159.1 permission execution](https://github.com/openai/codex/blob/rust-v0.159.1/codex-rs/hooks/src/events/permission_request.rs),
[Codex output parser](https://github.com/openai/codex/blob/rust-v0.159.1/codex-rs/hooks/src/engine/output_parser.rs).

````
