Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/changelog/260401.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,44 @@ Added public-repo metadata, support docs, CI verification, and install smoke cov

### QA Notes
- N/A

## 260414-10:30:57 - Harden tmux runtime for non-default shells

### Summary
Reworked the tmux execution runtime to stop depending on the tmux server's interactive shell behavior and to make session lifecycle tracking explicit and shell-independent.

### Added
- Added a shared tmux runtime module with explicit session state, runner ownership, and tests for terminal lifecycle behavior.

### Changed
- Replaced the older shell-dependent tmux spawning path with a runner-based runtime compatible with non-default shells and customized tmux defaults.
- Simplified `commands/tmux.py` and `adapters/tmux.py` to use the shared tmux runtime instead of duplicating lifecycle logic.
- Updated packaged tmux guidance to match the new runtime behavior.

### Files
- `source/src/story_automator/core/tmux_runtime.py`
- `source/src/story_automator/commands/tmux.py`
- `source/src/story_automator/adapters/tmux.py`
- `source/tests/test_tmux_runtime.py`
- `payload/.claude/skills/bmad-story-automator/data/tmux-commands.md`

### QA Notes
- `python3 -m compileall source/src`
- `PYTHONPATH=source/src python3 -m unittest source.tests.test_tmux_runtime`

## 260414-12:07:46 - Fix Claude runner sessions that stay open at the prompt after command completion

### Summary
Fixed a runner-mode regression where Claude command sessions could finish their work, return to the interactive prompt, and still remain marked `running`, causing `monitor-session` to hang instead of advancing the orchestrator.

### Changed
- Updated runner-mode status detection to treat Claude sessions as complete when the pane shows the idle prompt together with a completion marker, even if the Claude process remains open interactively.
- Relaxed prompt detection so Claude's prompt is recognized when it appears above the status panel rather than only on the final captured line.

### Files
- `source/src/story_automator/core/tmux_runtime.py`
- `source/tests/test_tmux_runtime.py`

### QA Notes
- `PYTHONPATH=source/src python3 -m unittest source.tests.test_tmux_runtime`
- `python3 -m compileall source/src`
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ The status check script automatically detects Claude vs Codex sessions:
- **Codex completion cues:** `tokens used` line, shell prompt return (e.g., `❯`, `$`, `#`), or clean tmux exit
- Codex sessions get 1.5x longer wait estimates (90s vs 60s default); "succeeded" alone is not treated as active

**Runtime Behavior (v1.13.0):**
- Normal `tmux-wrapper spawn` now uses a runner-based tmux path with explicit session state, not `tmux send-keys`
- Lifecycle truth comes from the session state file first; pane capture is still used for exported `output_file` artifacts
- Sessions keep dead panes with `remain-on-exit on`, so `pane_dead` and `pane_dead_status` remain inspectable after completion
- Temporary migration switch: `SA_TMUX_RUNTIME=legacy|runner|auto` (`auto` is the default)

**For full output (when completed/stuck):**
```bash
script="$(printf "%s" "{project_root}/.claude/skills/bmad-story-automator/scripts/story-automator")"
Expand Down Expand Up @@ -150,14 +156,20 @@ This environment variable tells the stop hook to allow the session to complete n
Without it, the stop hook will block child sessions from stopping, causing infinite loops.

```bash
# CRITICAL: Always use -x 200 -y 50 for wide terminal (prevents line-wrap issues with long commands)
tmux new-session -d -s "SESSION_NAME" -x 200 -y 50 -c "PROJECT_PATH" -e STORY_AUTOMATOR_CHILD=true
tmux send-keys -t "SESSION_NAME" "COMMAND_HERE" Enter
# Current implementation:
# 1. create the session with an inert placeholder command
# 2. set remain-on-exit on the pane/session
# 3. respawn the pane into a bash runner that executes the per-session command file
tmux new-session -d -s "SESSION_NAME" -x 200 -y 50 -c "PROJECT_PATH" \
-e STORY_AUTOMATOR_CHILD=true -e AI_AGENT=codex -e CLAUDECODE= -e BASH_ENV= \
/bin/sleep 86400
tmux set-option -t "PANE_ID" remain-on-exit on
tmux respawn-pane -k -t "PANE_ID" /usr/bin/bash "/tmp/.sa-<hash>-session-SESSION_NAME-runner.sh"
```

**Terminal Dimensions:** The `-x 200 -y 50` flags create a wider terminal window. This is **REQUIRED** for commands longer than 80 characters (e.g., YOLO mode retrospective prompts ~1500 chars). Without this, line-wrapping causes shell parsing failures and silent command execution failures.
**Terminal Dimensions:** The `-x 200 -y 50` flags remain required. They preserve the wide pane geometry used for interactive agent sessions and pane-derived transcripts.

**Long Command Script Files:** Commands exceeding 500 characters are written to `/tmp/sa-cmd-{session}.sh` and executed via `bash /tmp/sa-cmd-{session}.sh`. The `bash` prefix is critical — without it, the shell receives a raw path and silently fails. These script files are not auto-cleaned; they persist in `/tmp/` until system cleanup.
**Command Files:** The runtime now always writes a per-session command file and a per-session runner file. This removes the old short-command vs long-command split and avoids quoting or line-wrap failures from `send-keys`. Explicit `tmux-wrapper kill` deletes these artifacts; stale terminal artifacts are garbage-collected after the retention TTL.

See `data/tmux-long-command-debugging.md` for detailed troubleshooting.

Expand Down
248 changes: 25 additions & 223 deletions source/src/story_automator/adapters/tmux.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
from __future__ import annotations

import json
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path

from ..core.common import (
clamp_int,
command_exists,
ensure_dir,
file_exists,
filter_input_box,
iso_now,
md5_hex8,
project_root,
read_text,
run_cmd,
safe_int,
trim_lines,
write_atomic,
from ..core.common import ensure_dir, run_cmd, trim_lines
from ..core.tmux_runtime import (
agent_cli,
agent_type,
detect_codex_session,
estimate_wait,
extract_active_task,
generate_session_name,
heartbeat_check,
load_session_state,
pane_status,
project_hash,
project_slug,
save_session_state,
session_status,
skill_prefix,
tmux_display,
tmux_has_session,
tmux_kill_session,
tmux_list_sessions as _tmux_list_sessions,
tmux_show_environment,
verify_or_create_output,
)


Expand All @@ -33,57 +37,6 @@ class TmuxStatus:
wait_estimate: int
session_state: str


def project_slug() -> str:
base = project_root().name.lower()
slug = "".join(ch for ch in base if ch.isalnum())[:8]
return slug or "project"


def project_hash(root: str | Path | None = None) -> str:
resolved = Path(root) if root else project_root()
return md5_hex8(str(Path(resolved).resolve()))


def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") -> str:
stamp = time.strftime("%y%m%d-%H%M%S", time.localtime())
suffix = story_id.replace(".", "-")
name = f"sa-{project_slug()}-{stamp}-e{epic}-s{suffix}-{step}"
if cycle:
name += f"-r{cycle}"
return name


def agent_type() -> str:
return os.environ.get("AI_AGENT", "claude")


def agent_cli(agent: str) -> str:
return "codex exec" if agent == "codex" else "claude --dangerously-skip-permissions"


def skill_prefix(agent: str) -> str:
return "none" if agent == "codex" else "bmad-"


def tmux_has_session(session: str) -> bool:
_, code = run_cmd("tmux", "has-session", "-t", session)
return code == 0


def tmux_display(session: str, fmt: str) -> str:
output, _ = run_cmd("tmux", "display-message", "-t", session, "-p", fmt)
return output.strip()


def tmux_show_environment(session: str, key: str) -> str:
output, code = run_cmd("tmux", "show-environment", "-t", session, key)
if code != 0:
return ""
parts = output.strip().split("=", 1)
return parts[1] if len(parts) == 2 else ""


def tmux_new_session(session: str, root: str | Path, selected_agent: str) -> tuple[str, int]:
return run_cmd(
"tmux",
Expand Down Expand Up @@ -114,101 +67,17 @@ def tmux_send_keys(session: str, command: str, enter: bool = True) -> tuple[str,


def tmux_list_sessions(project_only: bool = False) -> list[str]:
if not command_exists("tmux"):
return []
output, code = run_cmd("tmux", "list-sessions", "-F", "#{session_name}")
if code != 0:
return []
sessions = [line for line in trim_lines(output) if line.startswith("sa-")]
if project_only:
prefix = f"sa-{project_slug()}-"
sessions = [line for line in sessions if line.startswith(prefix)]
sessions, _ = _tmux_list_sessions(project_only)
return sessions


def tmux_kill_session(session: str) -> None:
run_cmd("tmux", "kill-session", "-t", session)
hash_value = project_hash()
for file_name in (
f"/tmp/.sa-{hash_value}-session-{session}-state.json",
f"/tmp/sa-{hash_value}-output-{session}.txt",
f"/tmp/sa-cmd-{session}.sh",
):
Path(file_name).unlink(missing_ok=True)


def pane_status(session: str) -> str:
pane_dead = tmux_display(session, "#{pane_dead}")
exit_status = tmux_display(session, "#{pane_dead_status}")
if pane_dead == "1":
if exit_status and exit_status != "0":
return f"crashed:{exit_status}"
return "exited:0"
return "alive"


def detect_codex_session(session: str, capture: str) -> str:
if tmux_show_environment(session, "AI_AGENT") == "codex":
return "codex"
if re.search(r"(?i)OpenAI Codex|codex exec|gpt-[0-9]+-codex|tokens used|codex-cli", capture):
return "codex"
return "claude"


def estimate_wait(task: str, done: int, total: int) -> int:
lower = task.lower()
if re.search(r"loading|reading|searching|parsing", lower):
return 30
if re.search(r"presenting|waiting|menu|select|choose", lower):
return 15
if re.search(r"running tests|testing|building|compiling|installing", lower):
return 120
if re.search(r"writing|editing|updating|creating|fixing", lower):
return 60
if total > 0:
progress = 100 * done // total
if progress < 25:
return 90
if progress < 50:
return 75
if progress < 75:
return 60
return 30
return 60


def extract_active_task(capture: str) -> str:
pattern = re.compile(r"(?i)(·|✳|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|✶|✻|Galloping|Working|Running|Beaming|Razzmatazzing|Creating)")
active = ""
for line in trim_lines(capture):
if pattern.search(line):
active = line
if not active:
return ""
active = re.sub(r"[·✳⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✶✻]", "", active)
active = re.sub(r"\(ctrl\+c.*", "", active).strip()
return active[:80]


def parse_statusline_time(capture: str) -> str:
pattern = re.compile(r"\| [0-9]{2}:[0-9]{2}:[0-9]{2}")
last = ""
for line in trim_lines(capture):
matches = pattern.findall(line)
if matches:
last = matches[-1].replace("|", "").strip()
return last


def load_json_state(path: str | Path) -> dict[str, object]:
if not file_exists(path):
return {}
return json.loads(read_text(path))
return load_session_state(path)


def save_json_state(path: str | Path, payload: dict[str, object]) -> None:
ensure_dir(Path(path).parent)
write_atomic(path, json.dumps(payload))
save_session_state(path, payload)


def count_rune(text: str, target: str) -> int:
Expand All @@ -220,70 +89,3 @@ def find_first_todo_line(capture: str) -> int:
if "☒" in line or "☐" in line:
return index
return 999


def verify_or_create_output(output_file: str, session_name: str, hash_value: str) -> str:
if output_file and file_exists(output_file) and Path(output_file).stat().st_size > 0:
return output_file
fallback = Path(f"/tmp/sa-{hash_value}-output-{session_name}-fallback.txt")
if tmux_has_session(session_name):
capture, _ = run_cmd("tmux", "capture-pane", "-t", session_name, "-p", "-S", "-300")
lines = trim_lines(capture)[:200]
fallback.write_text("\n".join(lines), encoding="utf-8")
if fallback.exists() and fallback.stat().st_size > 0:
return str(fallback)
expected = Path(f"/tmp/sa-{hash_value}-output-{session_name}.txt")
if expected.exists() and expected.stat().st_size > 0:
return str(expected)
return ""


def heartbeat_check(session: str, selected_agent: str) -> tuple[str, float, str, str]:
if not session:
return "error", 0.0, "", "no_session"
if not tmux_has_session(session):
return "error", 0.0, "", "session_not_found"
pane_pid = tmux_display(session, "#{pane_pid}")
if not pane_pid:
return "error", 0.0, "", "no_pane_pid"
pattern = "codex" if selected_agent == "codex" else "claude"
agent_pid = _find_agent_pid(pane_pid, pattern, 0)
prompt = _check_prompt_visible(session)
if not agent_pid:
return ("completed" if prompt == "true" else "dead"), 0.0, "", prompt
cpu_output, _ = run_cmd("ps", "-o", "%cpu=", "-p", agent_pid)
cpu = float(cpu_output.strip() or 0.0)
status = "alive" if int(cpu) > 0 else "idle"
if prompt == "true":
status = "completed"
return status, cpu, agent_pid, prompt


def _find_agent_pid(parent: str, pattern: str, depth: int) -> str:
if depth > 4:
return ""
output, code = run_cmd("pgrep", "-P", parent)
if code != 0:
return ""
for child in trim_lines(output):
child = child.strip()
if not child:
continue
command, _ = run_cmd("ps", "-o", "comm=", "-p", child)
if pattern.lower() in command.lower():
return child
nested = _find_agent_pid(child, pattern, depth + 1)
if nested:
return nested
return ""


def _check_prompt_visible(session: str) -> str:
capture, _ = run_cmd("tmux", "capture-pane", "-t", session, "-p")
lines = trim_lines(capture)[-3:]
last = lines[-1].rstrip() if lines else ""
if re.search(r"❯\s*([0-9]+[smh]\s*)?[0-9]{1,2}:[0-9]{2}:[0-9]{2}\s*$", last):
return "true"
if re.search(r"(❯|\$|#|%)\s*$", last):
return "true"
return "false"
Loading
Loading