overcode 0.2.4__tar.gz → 0.2.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {overcode-0.2.4/src/overcode.egg-info → overcode-0.2.6}/PKG-INFO +1 -1
- {overcode-0.2.4 → overcode-0.2.6}/pyproject.toml +1 -1
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/agent.py +96 -7
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/daemon_utils.py +17 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/follow_mode.py +9 -8
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/hook_status_detector.py +4 -4
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/implementations.py +30 -15
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/launcher.py +197 -22
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/mocks.py +12 -14
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/monitor_daemon.py +64 -10
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/monitor_daemon_state.py +4 -1
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/pid_utils.py +63 -1
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/protocols.py +13 -13
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/session_manager.py +11 -5
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/sister_poller.py +1 -1
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/status_constants.py +1 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/status_detector.py +35 -27
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/status_detector_factory.py +3 -3
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/status_patterns.py +28 -14
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/summarizer_component.py +2 -2
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/summary_columns.py +2 -2
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/supervisor_daemon.py +16 -16
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tmux_manager.py +49 -22
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tmux_utils.py +18 -6
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui.py +92 -60
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_actions/daemon.py +16 -22
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_actions/session.py +41 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_logic.py +25 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/command_bar.py +24 -2
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/daemon_status_bar.py +3 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/help_overlay.py +2 -2
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/preview_pane.py +20 -1
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/session_summary.py +4 -9
- {overcode-0.2.4 → overcode-0.2.6/src/overcode.egg-info}/PKG-INFO +1 -1
- {overcode-0.2.4 → overcode-0.2.6}/LICENSE +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/MANIFEST.in +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/README.md +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/setup.cfg +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/__init__.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/agent_scanner.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/bundled_skills.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/claude_config.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/__init__.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/__main__.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/_shared.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/budget.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/config.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/daemon.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/hooks.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/monitoring.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/perms.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/sister.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/cli/skills.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/config.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/data_export.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/dependency_check.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/exceptions.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/history_reader.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/hook_handler.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/interfaces.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/logging_config.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/monitor_daemon_core.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/notifier.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/presence_logger.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/settings.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/sister_controller.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/status_history.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/summary_groups.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/supervisor_daemon_core.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/testing/__init__.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/testing/renderer.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/testing/tmux_driver.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/testing/tui_eye.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/testing/tui_eye_skill.md +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/time_context.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui.tcss +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_actions/__init__.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_actions/input.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_actions/navigation.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_actions/view.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_helpers.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_render.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/__init__.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/daemon_panel.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/status_timeline.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/usage_monitor.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web/__init__.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web/templates/analytics.html +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web/templates/dashboard.html +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web_api.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web_control_api.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web_server.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode/web_templates.py +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode.egg-info/SOURCES.txt +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode.egg-info/entry_points.txt +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.2.4 → overcode-0.2.6}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -212,6 +212,68 @@ def launch(
|
|
|
212
212
|
rprint("\nTo view: [bold]overcode attach[/bold]")
|
|
213
213
|
|
|
214
214
|
|
|
215
|
+
@app.command()
|
|
216
|
+
def fork(
|
|
217
|
+
source: Annotated[str, typer.Argument(help="Name of agent to fork from")],
|
|
218
|
+
name: Annotated[
|
|
219
|
+
Optional[str], typer.Option("--name", "-n", help="Name for the forked agent (default: <source>-fork)")
|
|
220
|
+
] = None,
|
|
221
|
+
prompt: Annotated[
|
|
222
|
+
Optional[str], typer.Option("--prompt", "-p", help="Initial prompt to send to the fork")
|
|
223
|
+
] = None,
|
|
224
|
+
session: SessionOption = "agents",
|
|
225
|
+
):
|
|
226
|
+
"""Fork an agent, creating a child with the source's conversation context.
|
|
227
|
+
|
|
228
|
+
Uses Claude's --resume --fork-session to branch the conversation history
|
|
229
|
+
into a new independent agent. The forked agent inherits the source's
|
|
230
|
+
directory, permissions, agent persona, and CLI flags.
|
|
231
|
+
|
|
232
|
+
Examples:
|
|
233
|
+
overcode fork my-agent # Fork as my-agent-fork
|
|
234
|
+
overcode fork my-agent -n side-analysis # Fork with custom name
|
|
235
|
+
overcode fork my-agent -p "analyze test failures" # Fork with initial prompt
|
|
236
|
+
"""
|
|
237
|
+
from ..session_manager import SessionManager
|
|
238
|
+
|
|
239
|
+
sm = SessionManager()
|
|
240
|
+
source_session = sm.get_session_by_name(source)
|
|
241
|
+
if not source_session:
|
|
242
|
+
rprint(f"[red]Error: Agent '{source}' not found[/red]")
|
|
243
|
+
raise typer.Exit(code=1)
|
|
244
|
+
|
|
245
|
+
if not source_session.active_claude_session_id:
|
|
246
|
+
rprint(f"[red]Error: Agent '{source}' has no active Claude session ID yet[/red]")
|
|
247
|
+
rprint("[dim]The monitor daemon detects session IDs periodically — try again shortly[/dim]")
|
|
248
|
+
raise typer.Exit(code=1)
|
|
249
|
+
|
|
250
|
+
if source_session.status in ("terminated", "done"):
|
|
251
|
+
rprint(f"[red]Error: Cannot fork a {source_session.status} agent[/red]")
|
|
252
|
+
raise typer.Exit(code=1)
|
|
253
|
+
|
|
254
|
+
# Default fork name
|
|
255
|
+
fork_name = name if name else f"{source}-fork"
|
|
256
|
+
|
|
257
|
+
launcher = ClaudeLauncher(session)
|
|
258
|
+
result = launcher.launch_fork(
|
|
259
|
+
name=fork_name,
|
|
260
|
+
source_session=source_session,
|
|
261
|
+
initial_prompt=prompt,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if result:
|
|
265
|
+
rprint(f"\n[green]✓[/green] Forked '[bold]{source}[/bold]' → '[bold]{fork_name}[/bold]'")
|
|
266
|
+
rprint(f" Directory: {result.start_directory}")
|
|
267
|
+
if result.claude_agent:
|
|
268
|
+
rprint(f" Agent: {result.claude_agent}")
|
|
269
|
+
if prompt:
|
|
270
|
+
rprint(" Initial prompt sent")
|
|
271
|
+
rprint(f"\nTo view: [bold]overcode attach {fork_name}[/bold]")
|
|
272
|
+
else:
|
|
273
|
+
rprint(f"[red]Error: Failed to fork '{source}'[/red]")
|
|
274
|
+
raise typer.Exit(code=1)
|
|
275
|
+
|
|
276
|
+
|
|
215
277
|
@app.command("list")
|
|
216
278
|
def list_agents(
|
|
217
279
|
name: Annotated[
|
|
@@ -464,6 +526,11 @@ def list_agents(
|
|
|
464
526
|
if terminated_count > 0:
|
|
465
527
|
rprint(f"\n[dim]{terminated_count} terminated session(s). Run 'overcode cleanup' to remove.[/dim]")
|
|
466
528
|
|
|
529
|
+
# Hint about untracked tmux windows (#344)
|
|
530
|
+
if use_daemon and daemon_state.untracked_window_count > 0:
|
|
531
|
+
n = daemon_state.untracked_window_count
|
|
532
|
+
rprint(f"\n[dim]{n} untracked tmux window(s). Run 'overcode cleanup --untracked' to remove.[/dim]")
|
|
533
|
+
|
|
467
534
|
|
|
468
535
|
@app.command()
|
|
469
536
|
def attach(
|
|
@@ -598,6 +665,9 @@ def cleanup(
|
|
|
598
665
|
done: Annotated[
|
|
599
666
|
bool, typer.Option("--done", help="Also archive 'done' child agents (#244)")
|
|
600
667
|
] = False,
|
|
668
|
+
untracked: Annotated[
|
|
669
|
+
bool, typer.Option("--untracked", help="Kill tmux windows not tracked by any agent (#344)")
|
|
670
|
+
] = False,
|
|
601
671
|
session: SessionOption = "agents",
|
|
602
672
|
):
|
|
603
673
|
"""Remove terminated sessions from tracking.
|
|
@@ -606,6 +676,7 @@ def cleanup(
|
|
|
606
676
|
(e.g., after a machine reboot). Use 'overcode list' to see them.
|
|
607
677
|
|
|
608
678
|
Use --done to also archive done child agents (kill tmux window, move to archive).
|
|
679
|
+
Use --untracked to kill tmux windows that exist but aren't tracked by any agent.
|
|
609
680
|
"""
|
|
610
681
|
launcher = ClaudeLauncher(session)
|
|
611
682
|
count = launcher.cleanup_terminated_sessions()
|
|
@@ -619,6 +690,10 @@ def cleanup(
|
|
|
619
690
|
launcher._kill_single_session(sess)
|
|
620
691
|
done_count += 1
|
|
621
692
|
|
|
693
|
+
# Kill untracked tmux windows (#344)
|
|
694
|
+
if untracked:
|
|
695
|
+
launcher.list_sessions(kill_untracked=True)
|
|
696
|
+
|
|
622
697
|
total = count + done_count
|
|
623
698
|
if total > 0:
|
|
624
699
|
parts = []
|
|
@@ -627,7 +702,7 @@ def cleanup(
|
|
|
627
702
|
if done_count > 0:
|
|
628
703
|
parts.append(f"{done_count} done")
|
|
629
704
|
rprint(f"[green]✓ Cleaned up {' + '.join(parts)} session(s)[/green]")
|
|
630
|
-
|
|
705
|
+
elif not untracked:
|
|
631
706
|
rprint("[dim]No sessions to clean up[/dim]")
|
|
632
707
|
|
|
633
708
|
|
|
@@ -775,6 +850,9 @@ def show(
|
|
|
775
850
|
stats_only: Annotated[
|
|
776
851
|
bool, typer.Option("--stats-only", "-s", help="Show only stats, no pane output")
|
|
777
852
|
] = False,
|
|
853
|
+
color: Annotated[
|
|
854
|
+
bool, typer.Option("--color", "-c", help="Preserve ANSI colors in pane output")
|
|
855
|
+
] = False,
|
|
778
856
|
session: SessionOption = "agents",
|
|
779
857
|
):
|
|
780
858
|
"""Show agent details and recent output."""
|
|
@@ -895,12 +973,23 @@ def show(
|
|
|
895
973
|
# Pane output section (skip if --stats-only or --lines 0)
|
|
896
974
|
if not stats_only and lines > 0:
|
|
897
975
|
if pane_content_raw:
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
976
|
+
if color:
|
|
977
|
+
from rich.console import Console
|
|
978
|
+
from rich.text import Text
|
|
979
|
+
console = Console()
|
|
980
|
+
raw_lines = pane_content_raw.rstrip().split('\n')
|
|
981
|
+
display_lines = raw_lines[-lines:]
|
|
982
|
+
console.print(f"=== {name} (last {lines} lines) ===")
|
|
983
|
+
for line in display_lines:
|
|
984
|
+
console.print(Text.from_ansi(line))
|
|
985
|
+
console.print(f"=== end {name} ===")
|
|
986
|
+
else:
|
|
987
|
+
clean_content = strip_ansi(pane_content_raw)
|
|
988
|
+
content_lines = clean_content.rstrip().split('\n')
|
|
989
|
+
display_lines = content_lines[-lines:]
|
|
990
|
+
print(f"=== {name} (last {lines} lines) ===")
|
|
991
|
+
print('\n'.join(display_lines))
|
|
992
|
+
print(f"=== end {name} ===")
|
|
904
993
|
else:
|
|
905
994
|
# Fallback for terminated sessions
|
|
906
995
|
output = launcher.get_session_output(name, lines=lines)
|
|
@@ -78,12 +78,29 @@ def create_daemon_helpers(
|
|
|
78
78
|
os.kill(pid, signal.SIGTERM)
|
|
79
79
|
# Wait for process to actually terminate before removing PID file
|
|
80
80
|
start = time.time()
|
|
81
|
+
terminated = False
|
|
81
82
|
while time.time() - start < 5.0:
|
|
82
83
|
try:
|
|
83
84
|
os.kill(pid, 0)
|
|
84
85
|
time.sleep(0.1)
|
|
85
86
|
except (OSError, ProcessLookupError):
|
|
87
|
+
terminated = True
|
|
86
88
|
break
|
|
89
|
+
|
|
90
|
+
if not terminated:
|
|
91
|
+
# Still alive after timeout — force kill
|
|
92
|
+
try:
|
|
93
|
+
os.kill(pid, signal.SIGKILL)
|
|
94
|
+
time.sleep(0.1)
|
|
95
|
+
except (OSError, ProcessLookupError):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# Reap zombie if we're the parent process
|
|
99
|
+
try:
|
|
100
|
+
os.waitpid(pid, os.WNOHANG)
|
|
101
|
+
except ChildProcessError:
|
|
102
|
+
pass # Not our child, or already reaped
|
|
103
|
+
|
|
87
104
|
remove_pid_file(pid_path)
|
|
88
105
|
return True
|
|
89
106
|
except (OSError, ProcessLookupError):
|
|
@@ -19,15 +19,16 @@ from .session_manager import SessionManager
|
|
|
19
19
|
from .status_patterns import strip_ansi
|
|
20
20
|
from .settings import get_session_dir
|
|
21
21
|
from .status_constants import DEFAULT_CAPTURE_LINES, STATUS_WAITING_OVERSIGHT
|
|
22
|
+
from .tmux_utils import tmux_window_target
|
|
22
23
|
|
|
23
24
|
|
|
24
|
-
def _capture_pane(tmux_session: str,
|
|
25
|
+
def _capture_pane(tmux_session: str, window_name: str, lines: int = DEFAULT_CAPTURE_LINES) -> Optional[str]:
|
|
25
26
|
"""Capture recent pane output via tmux."""
|
|
26
27
|
try:
|
|
27
28
|
result = subprocess.run(
|
|
28
29
|
[
|
|
29
30
|
"tmux", "capture-pane",
|
|
30
|
-
"-t",
|
|
31
|
+
"-t", tmux_window_target(tmux_session, window_name),
|
|
31
32
|
"-p",
|
|
32
33
|
"-S", f"-{lines}",
|
|
33
34
|
],
|
|
@@ -143,7 +144,7 @@ def follow_agent(
|
|
|
143
144
|
print(f"Error: Agent '{name}' not found", file=sys.stderr)
|
|
144
145
|
return 1
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
window_name = session.tmux_window
|
|
147
148
|
|
|
148
149
|
# Read oversight policy from session
|
|
149
150
|
oversight_policy = getattr(session, 'oversight_policy', 'wait') or 'wait'
|
|
@@ -162,7 +163,7 @@ def follow_agent(
|
|
|
162
163
|
try:
|
|
163
164
|
while not interrupted:
|
|
164
165
|
# Capture pane content
|
|
165
|
-
raw = _capture_pane(tmux_session,
|
|
166
|
+
raw = _capture_pane(tmux_session, window_name)
|
|
166
167
|
if raw is None:
|
|
167
168
|
if _check_session_terminated(sessions, name):
|
|
168
169
|
print(f"\n[follow] Agent '{name}' terminated", file=sys.stderr)
|
|
@@ -176,7 +177,7 @@ def follow_agent(
|
|
|
176
177
|
if _check_hook_stop(tmux_session, name):
|
|
177
178
|
# Wait one extra cycle to capture final output
|
|
178
179
|
time.sleep(poll_interval)
|
|
179
|
-
raw = _capture_pane(tmux_session,
|
|
180
|
+
raw = _capture_pane(tmux_session, window_name)
|
|
180
181
|
if raw:
|
|
181
182
|
for line in raw.rstrip().split('\n'):
|
|
182
183
|
cleaned = strip_ansi(line).strip()
|
|
@@ -226,7 +227,7 @@ def follow_agent(
|
|
|
226
227
|
|
|
227
228
|
# Enter report-polling sub-loop
|
|
228
229
|
return _poll_for_report(
|
|
229
|
-
name, tmux_session, sessions,
|
|
230
|
+
name, tmux_session, sessions, window_name,
|
|
230
231
|
oversight_policy, oversight_timeout_seconds,
|
|
231
232
|
poll_interval, recent_lines,
|
|
232
233
|
)
|
|
@@ -253,7 +254,7 @@ def _poll_for_report(
|
|
|
253
254
|
name: str,
|
|
254
255
|
tmux_session: str,
|
|
255
256
|
sessions: SessionManager,
|
|
256
|
-
|
|
257
|
+
window_name: str,
|
|
257
258
|
oversight_policy: str,
|
|
258
259
|
oversight_timeout_seconds: float,
|
|
259
260
|
poll_interval: float,
|
|
@@ -307,7 +308,7 @@ def _poll_for_report(
|
|
|
307
308
|
return 1
|
|
308
309
|
|
|
309
310
|
# Continue streaming pane output while waiting
|
|
310
|
-
raw = _capture_pane(tmux_session,
|
|
311
|
+
raw = _capture_pane(tmux_session, window_name)
|
|
311
312
|
if raw:
|
|
312
313
|
_emit_new_lines(raw, recent_lines)
|
|
313
314
|
|
|
@@ -140,13 +140,13 @@ class HookStatusDetector:
|
|
|
140
140
|
|
|
141
141
|
return data
|
|
142
142
|
|
|
143
|
-
def get_pane_content(self, window:
|
|
143
|
+
def get_pane_content(self, window: str, num_lines: int = 0) -> Optional[str]:
|
|
144
144
|
"""Get pane content via the polling detector's tmux interface."""
|
|
145
145
|
fallback = self._get_polling_fallback()
|
|
146
146
|
fallback.capture_lines = self.capture_lines
|
|
147
147
|
return fallback.get_pane_content(window, num_lines)
|
|
148
148
|
|
|
149
|
-
def detect_status(self, session: "Session") -> Tuple[str, str, str]:
|
|
149
|
+
def detect_status(self, session: "Session", num_lines: int = 0) -> Tuple[str, str, str]:
|
|
150
150
|
"""Detect session status using hook state files.
|
|
151
151
|
|
|
152
152
|
When fresh hook state exists, uses that for status determination.
|
|
@@ -160,7 +160,7 @@ class HookStatusDetector:
|
|
|
160
160
|
|
|
161
161
|
if hook_state is None:
|
|
162
162
|
# No hook state or stale → full polling fallback
|
|
163
|
-
return self._get_polling_fallback().detect_status(session)
|
|
163
|
+
return self._get_polling_fallback().detect_status(session, num_lines=num_lines)
|
|
164
164
|
|
|
165
165
|
# Hook state is fresh → use it for status
|
|
166
166
|
event = hook_state.get("event", "")
|
|
@@ -183,7 +183,7 @@ class HookStatusDetector:
|
|
|
183
183
|
status = STATUS_WAITING_OVERSIGHT
|
|
184
184
|
|
|
185
185
|
# Read pane for activity enrichment and content return value
|
|
186
|
-
pane_content = self.get_pane_content(session.tmux_window) or ""
|
|
186
|
+
pane_content = self.get_pane_content(session.tmux_window, num_lines=num_lines) or ""
|
|
187
187
|
|
|
188
188
|
# Check for busy-sleeping: agent is "running" but executing a sleep command (#289)
|
|
189
189
|
if status == STATUS_RUNNING:
|
|
@@ -65,17 +65,27 @@ class RealTmux:
|
|
|
65
65
|
except (LibTmuxException, ObjectDoesNotExist):
|
|
66
66
|
return None
|
|
67
67
|
|
|
68
|
-
def _get_window(self, session: str, window:
|
|
69
|
-
"""Get a window by session name and window
|
|
68
|
+
def _get_window(self, session: str, window: str) -> Optional[libtmux.Window]:
|
|
69
|
+
"""Get a window by session name and window name.
|
|
70
|
+
|
|
71
|
+
Falls back to index-based lookup for legacy sessions that still have
|
|
72
|
+
digit-string window values (e.g. "4" from pre-name-based era).
|
|
73
|
+
"""
|
|
70
74
|
sess = self._get_session(session)
|
|
71
75
|
if sess is None:
|
|
72
76
|
return None
|
|
73
77
|
try:
|
|
74
|
-
return sess.windows.get(
|
|
78
|
+
return sess.windows.get(window_name=window)
|
|
75
79
|
except (LibTmuxException, ObjectDoesNotExist):
|
|
80
|
+
# Fallback: if window looks like a legacy index, try index lookup
|
|
81
|
+
if window.isdigit():
|
|
82
|
+
try:
|
|
83
|
+
return sess.windows.get(window_index=window)
|
|
84
|
+
except (LibTmuxException, ObjectDoesNotExist):
|
|
85
|
+
pass
|
|
76
86
|
return None
|
|
77
87
|
|
|
78
|
-
def _get_pane(self, session: str, window:
|
|
88
|
+
def _get_pane(self, session: str, window: str) -> Optional[libtmux.Pane]:
|
|
79
89
|
"""Get the first pane of a window, with caching."""
|
|
80
90
|
cache_key = (session, window)
|
|
81
91
|
now = time.time()
|
|
@@ -94,7 +104,7 @@ class RealTmux:
|
|
|
94
104
|
self._pane_cache[cache_key] = (pane, now)
|
|
95
105
|
return pane
|
|
96
106
|
|
|
97
|
-
def invalidate_cache(self, session: str = None, window:
|
|
107
|
+
def invalidate_cache(self, session: str = None, window: str = None) -> None:
|
|
98
108
|
"""Invalidate cached objects.
|
|
99
109
|
|
|
100
110
|
Args:
|
|
@@ -113,7 +123,7 @@ class RealTmux:
|
|
|
113
123
|
for k in keys_to_remove:
|
|
114
124
|
del self._pane_cache[k]
|
|
115
125
|
|
|
116
|
-
def capture_pane(self, session: str, window:
|
|
126
|
+
def capture_pane(self, session: str, window: str, lines: int = 100) -> Optional[str]:
|
|
117
127
|
try:
|
|
118
128
|
pane = self._get_pane(session, window)
|
|
119
129
|
if pane is None:
|
|
@@ -129,7 +139,7 @@ class RealTmux:
|
|
|
129
139
|
self.invalidate_cache(session, window)
|
|
130
140
|
return None
|
|
131
141
|
|
|
132
|
-
def send_keys(self, session: str, window:
|
|
142
|
+
def send_keys(self, session: str, window: str, keys: str, enter: bool = True) -> bool:
|
|
133
143
|
try:
|
|
134
144
|
pane = self._get_pane(session, window)
|
|
135
145
|
if pane is None:
|
|
@@ -188,7 +198,7 @@ class RealTmux:
|
|
|
188
198
|
return False
|
|
189
199
|
|
|
190
200
|
def new_window(self, session: str, name: str, command: Optional[List[str]] = None,
|
|
191
|
-
cwd: Optional[str] = None) -> Optional[
|
|
201
|
+
cwd: Optional[str] = None) -> Optional[str]:
|
|
192
202
|
try:
|
|
193
203
|
sess = self._get_session(session)
|
|
194
204
|
if sess is None:
|
|
@@ -201,11 +211,14 @@ class RealTmux:
|
|
|
201
211
|
kwargs['window_shell'] = ' '.join(command)
|
|
202
212
|
|
|
203
213
|
window = sess.new_window(**kwargs)
|
|
204
|
-
|
|
214
|
+
# Prevent tmux from auto-renaming the window based on the
|
|
215
|
+
# running process — we rely on stable window names for lookups.
|
|
216
|
+
window.set_window_option('automatic-rename', 'off')
|
|
217
|
+
return window.window_name
|
|
205
218
|
except (LibTmuxException, ValueError):
|
|
206
219
|
return None
|
|
207
220
|
|
|
208
|
-
def kill_window(self, session: str, window:
|
|
221
|
+
def kill_window(self, session: str, window: str) -> bool:
|
|
209
222
|
try:
|
|
210
223
|
win = self._get_window(session, window)
|
|
211
224
|
if win is None:
|
|
@@ -242,16 +255,18 @@ class RealTmux:
|
|
|
242
255
|
except LibTmuxException:
|
|
243
256
|
return []
|
|
244
257
|
|
|
245
|
-
def attach(self, session: str, window: Optional[
|
|
258
|
+
def attach(self, session: str, window: Optional[str] = None, bare: bool = False) -> None:
|
|
246
259
|
if bare:
|
|
247
260
|
self._attach_bare(session, window)
|
|
248
261
|
else:
|
|
249
|
-
|
|
262
|
+
from .tmux_utils import tmux_window_target
|
|
263
|
+
target = tmux_window_target(session, window) if window is not None else session
|
|
250
264
|
os.execlp("tmux", "tmux", "attach-session", "-t", target)
|
|
251
265
|
|
|
252
|
-
def _attach_bare(self, session: str, window:
|
|
266
|
+
def _attach_bare(self, session: str, window: str) -> None:
|
|
253
267
|
"""Create a linked session with stripped chrome and attach to it."""
|
|
254
268
|
import subprocess
|
|
269
|
+
from .tmux_utils import tmux_window_target
|
|
255
270
|
|
|
256
271
|
bare_session = f"bare-{session}-{window}"
|
|
257
272
|
|
|
@@ -271,13 +286,13 @@ class RealTmux:
|
|
|
271
286
|
["tmux", "set", "-t", bare_session, "status", "off"],
|
|
272
287
|
["tmux", "set", "-t", bare_session, "mouse", "off"],
|
|
273
288
|
["tmux", "set", "-t", bare_session, "destroy-unattached", "on"],
|
|
274
|
-
["tmux", "select-window", "-t",
|
|
289
|
+
["tmux", "select-window", "-t", tmux_window_target(bare_session, window)],
|
|
275
290
|
]:
|
|
276
291
|
subprocess.run(cmd, capture_output=True)
|
|
277
292
|
|
|
278
293
|
os.execlp("tmux", "tmux", "attach-session", "-t", bare_session)
|
|
279
294
|
|
|
280
|
-
def select_window(self, session: str, window:
|
|
295
|
+
def select_window(self, session: str, window: str) -> bool:
|
|
281
296
|
"""Select a window in a tmux session (for external pane sync)."""
|
|
282
297
|
try:
|
|
283
298
|
win = self._get_window(session, window)
|