overcode 0.2.3__tar.gz → 0.2.5__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.
Files changed (111) hide show
  1. {overcode-0.2.3/src/overcode.egg-info → overcode-0.2.5}/PKG-INFO +1 -1
  2. {overcode-0.2.3 → overcode-0.2.5}/pyproject.toml +1 -1
  3. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/agent.py +14 -1
  4. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/implementations.py +11 -0
  5. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/monitor_daemon.py +59 -8
  6. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/monitor_daemon_state.py +3 -0
  7. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/settings.py +7 -0
  8. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/status_constants.py +82 -4
  9. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/summary_columns.py +54 -37
  10. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tmux_manager.py +11 -0
  11. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui.py +206 -17
  12. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui.tcss +16 -0
  13. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_actions/session.py +2 -1
  14. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_actions/view.py +21 -0
  15. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_helpers.py +3 -2
  16. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/__init__.py +2 -0
  17. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/command_bar.py +3 -2
  18. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/daemon_status_bar.py +3 -0
  19. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/help_overlay.py +5 -4
  20. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/session_summary.py +11 -12
  21. overcode-0.2.5/src/overcode/tui_widgets/sister_selection_modal.py +154 -0
  22. {overcode-0.2.3 → overcode-0.2.5/src/overcode.egg-info}/PKG-INFO +1 -1
  23. {overcode-0.2.3 → overcode-0.2.5}/src/overcode.egg-info/SOURCES.txt +1 -0
  24. {overcode-0.2.3 → overcode-0.2.5}/LICENSE +0 -0
  25. {overcode-0.2.3 → overcode-0.2.5}/MANIFEST.in +0 -0
  26. {overcode-0.2.3 → overcode-0.2.5}/README.md +0 -0
  27. {overcode-0.2.3 → overcode-0.2.5}/setup.cfg +0 -0
  28. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/__init__.py +0 -0
  29. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/agent_scanner.py +0 -0
  30. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/bundled_skills.py +0 -0
  31. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/claude_config.py +0 -0
  32. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/__init__.py +0 -0
  33. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/__main__.py +0 -0
  34. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/_shared.py +0 -0
  35. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/budget.py +0 -0
  36. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/config.py +0 -0
  37. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/daemon.py +0 -0
  38. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/hooks.py +0 -0
  39. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/monitoring.py +0 -0
  40. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/perms.py +0 -0
  41. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/sister.py +0 -0
  42. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/cli/skills.py +0 -0
  43. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/config.py +0 -0
  44. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/daemon_claude_skill.md +0 -0
  45. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/daemon_logging.py +0 -0
  46. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/daemon_utils.py +0 -0
  47. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/data_export.py +0 -0
  48. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/dependency_check.py +0 -0
  49. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/exceptions.py +0 -0
  50. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/follow_mode.py +0 -0
  51. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/history_reader.py +0 -0
  52. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/hook_handler.py +0 -0
  53. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/hook_status_detector.py +0 -0
  54. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/interfaces.py +0 -0
  55. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/launcher.py +0 -0
  56. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/logging_config.py +0 -0
  57. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/mocks.py +0 -0
  58. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/monitor_daemon_core.py +0 -0
  59. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/notifier.py +0 -0
  60. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/pid_utils.py +0 -0
  61. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/presence_logger.py +0 -0
  62. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/protocols.py +0 -0
  63. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/session_manager.py +0 -0
  64. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/sister_controller.py +0 -0
  65. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/sister_poller.py +0 -0
  66. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/standing_instructions.py +0 -0
  67. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/status_detector.py +0 -0
  68. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/status_detector_factory.py +0 -0
  69. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/status_history.py +0 -0
  70. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/status_patterns.py +0 -0
  71. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/summarizer_client.py +0 -0
  72. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/summarizer_component.py +0 -0
  73. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/summary_groups.py +0 -0
  74. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/supervisor_daemon.py +0 -0
  75. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/supervisor_daemon_core.py +0 -0
  76. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/supervisor_layout.sh +0 -0
  77. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/testing/__init__.py +0 -0
  78. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/testing/renderer.py +0 -0
  79. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/testing/tmux_driver.py +0 -0
  80. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/testing/tui_eye.py +0 -0
  81. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/testing/tui_eye_skill.md +0 -0
  82. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/time_context.py +0 -0
  83. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tmux_utils.py +0 -0
  84. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_actions/__init__.py +0 -0
  85. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_actions/daemon.py +0 -0
  86. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_actions/input.py +0 -0
  87. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_actions/navigation.py +0 -0
  88. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_logic.py +0 -0
  89. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_render.py +0 -0
  90. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
  91. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/daemon_panel.py +0 -0
  92. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  93. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
  94. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/preview_pane.py +0 -0
  95. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/status_timeline.py +0 -0
  96. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  97. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/usage_monitor.py +0 -0
  98. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web/__init__.py +0 -0
  99. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web/templates/analytics.html +0 -0
  100. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web/templates/dashboard.html +0 -0
  101. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web_api.py +0 -0
  102. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web_chartjs.py +0 -0
  103. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web_control_api.py +0 -0
  104. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web_server.py +0 -0
  105. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web_server_runner.py +0 -0
  106. {overcode-0.2.3 → overcode-0.2.5}/src/overcode/web_templates.py +0 -0
  107. {overcode-0.2.3 → overcode-0.2.5}/src/overcode.egg-info/dependency_links.txt +0 -0
  108. {overcode-0.2.3 → overcode-0.2.5}/src/overcode.egg-info/entry_points.txt +0 -0
  109. {overcode-0.2.3 → overcode-0.2.5}/src/overcode.egg-info/requires.txt +0 -0
  110. {overcode-0.2.3 → overcode-0.2.5}/src/overcode.egg-info/top_level.txt +0 -0
  111. {overcode-0.2.3 → overcode-0.2.5}/tests/test_e2e_multi_agent_jokes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overcode
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: A supervisor for managing multiple Claude Code instances in tmux
5
5
  Author: Mike Bond
6
6
  Project-URL: Homepage, https://github.com/mkb23/overcode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "overcode"
7
- version = "0.2.3"
7
+ version = "0.2.5"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -464,6 +464,11 @@ def list_agents(
464
464
  if terminated_count > 0:
465
465
  rprint(f"\n[dim]{terminated_count} terminated session(s). Run 'overcode cleanup' to remove.[/dim]")
466
466
 
467
+ # Hint about untracked tmux windows (#344)
468
+ if use_daemon and daemon_state.untracked_window_count > 0:
469
+ n = daemon_state.untracked_window_count
470
+ rprint(f"\n[dim]{n} untracked tmux window(s). Run 'overcode cleanup --untracked' to remove.[/dim]")
471
+
467
472
 
468
473
  @app.command()
469
474
  def attach(
@@ -598,6 +603,9 @@ def cleanup(
598
603
  done: Annotated[
599
604
  bool, typer.Option("--done", help="Also archive 'done' child agents (#244)")
600
605
  ] = False,
606
+ untracked: Annotated[
607
+ bool, typer.Option("--untracked", help="Kill tmux windows not tracked by any agent (#344)")
608
+ ] = False,
601
609
  session: SessionOption = "agents",
602
610
  ):
603
611
  """Remove terminated sessions from tracking.
@@ -606,6 +614,7 @@ def cleanup(
606
614
  (e.g., after a machine reboot). Use 'overcode list' to see them.
607
615
 
608
616
  Use --done to also archive done child agents (kill tmux window, move to archive).
617
+ Use --untracked to kill tmux windows that exist but aren't tracked by any agent.
609
618
  """
610
619
  launcher = ClaudeLauncher(session)
611
620
  count = launcher.cleanup_terminated_sessions()
@@ -619,6 +628,10 @@ def cleanup(
619
628
  launcher._kill_single_session(sess)
620
629
  done_count += 1
621
630
 
631
+ # Kill untracked tmux windows (#344)
632
+ if untracked:
633
+ launcher.list_sessions(kill_untracked=True)
634
+
622
635
  total = count + done_count
623
636
  if total > 0:
624
637
  parts = []
@@ -627,7 +640,7 @@ def cleanup(
627
640
  if done_count > 0:
628
641
  parts.append(f"{done_count} done")
629
642
  rprint(f"[green]✓ Cleaned up {' + '.join(parts)} session(s)[/green]")
630
- else:
643
+ elif not untracked:
631
644
  rprint("[dim]No sessions to clean up[/dim]")
632
645
 
633
646
 
@@ -151,6 +151,17 @@ class RealTmux:
151
151
  if rest:
152
152
  pane.send_keys(rest, enter=False)
153
153
  time.sleep(0.1)
154
+ elif keys.startswith('/') and len(keys) > 1:
155
+ # Special handling for slash commands (#307)
156
+ # Claude Code shows a command menu when / is typed;
157
+ # send / separately so the menu has time to appear
158
+ # before the rest of the command and Enter arrive.
159
+ pane.send_keys('/', enter=False)
160
+ time.sleep(0.3)
161
+ rest = keys[1:]
162
+ if rest:
163
+ pane.send_keys(rest, enter=False)
164
+ time.sleep(0.15)
154
165
  else:
155
166
  pane.send_keys(keys, enter=False)
156
167
  # Small delay for Claude Code to process text
@@ -679,14 +679,37 @@ class MonitorDaemon:
679
679
 
680
680
  # Archive: kill tmux window and mark terminated
681
681
  try:
682
- from .tmux_utils import TmuxHelper
683
- tmux = TmuxHelper()
682
+ from .implementations import RealTmux
683
+ tmux = RealTmux()
684
684
  tmux.kill_window(self.tmux_session, session.tmux_window)
685
685
  except Exception:
686
686
  pass # Window may already be gone
687
687
  self.session_manager.update_session_status(session.id, "terminated")
688
688
  self.log.info(f"Auto-archived done agent: {session.name}")
689
689
 
690
+ def _count_untracked_windows(self, sessions: list) -> int:
691
+ """Count tmux windows not tracked by any active session (#344).
692
+
693
+ Returns count of windows that exist in tmux but aren't tracked
694
+ (excluding window 0 which is the default shell).
695
+ """
696
+ try:
697
+ from .implementations import RealTmux
698
+ tmux = RealTmux()
699
+ if not tmux.session_exists(self.tmux_session):
700
+ return 0
701
+ tmux_windows = tmux.list_windows(self.tmux_session)
702
+ active_sessions = [s for s in sessions if s.status != "terminated"]
703
+ tracked_windows = {s.tmux_window for s in active_sessions}
704
+ count = 0
705
+ for window_info in tmux_windows:
706
+ window_idx = int(window_info['index'])
707
+ if window_idx != 0 and window_idx not in tracked_windows:
708
+ count += 1
709
+ return count
710
+ except Exception:
711
+ return 0
712
+
690
713
  def _enforce_oversight_timeouts(self, sessions: list) -> None:
691
714
  """Enforce oversight timeouts for waiting_oversight sessions."""
692
715
  now = datetime.now()
@@ -849,12 +872,30 @@ class MonitorDaemon:
849
872
  session_states = []
850
873
  all_waiting_user = True
851
874
 
875
+ # Detect window index collisions: when multiple sessions share
876
+ # a window index, only the most recently launched one owns it.
877
+ # Others are truly terminated (their window was reused).
878
+ from collections import Counter
879
+ window_counts = Counter(s.tmux_window for s in sessions if s.status != "done")
880
+ # For colliding windows, find the rightful owner (latest start_time)
881
+ window_owner = {}
882
+ for s in sessions:
883
+ if s.status == "done":
884
+ continue
885
+ w = s.tmux_window
886
+ if window_counts[w] > 1:
887
+ # Multiple sessions claim this window — latest launch wins
888
+ if w not in window_owner or s.start_time > window_owner[w].start_time:
889
+ window_owner[w] = s
890
+
852
891
  for session in sessions:
853
- # Skip sessions already known to be terminated/done
854
- # avoids a wasted tmux call and prevents desync where
855
- # detect_status returns waiting_user for a gone window.
856
- if session.status == "terminated":
892
+ # If this window is contested and we're not the owner, skip
893
+ pane_content = ""
894
+ if (window_counts.get(session.tmux_window, 0) > 1
895
+ and window_owner.get(session.tmux_window) is not session):
857
896
  status, activity = STATUS_TERMINATED, "Session terminated"
897
+ if session.status != "terminated":
898
+ self.session_manager.update_session_status(session.id, "terminated")
858
899
  elif session.status == "done":
859
900
  status, activity = STATUS_DONE, "Completed"
860
901
  else:
@@ -912,9 +953,17 @@ class MonitorDaemon:
912
953
  else:
913
954
  effective_status = status
914
955
 
915
- # Persist terminated status so future loops skip detect_status
916
- if effective_status == STATUS_TERMINATED and session.status != "terminated":
956
+ # Persist terminated status when window is truly gone.
957
+ # Only persist when pane_content is empty (window gone), not when
958
+ # a shell prompt is briefly visible (e.g. during agent revival).
959
+ if (effective_status == STATUS_TERMINATED
960
+ and session.status != "terminated"
961
+ and not pane_content):
917
962
  self.session_manager.update_session_status(session.id, "terminated")
963
+ # Un-persist terminated if agent is found alive (revival or false positive)
964
+ elif (session.status == "terminated"
965
+ and effective_status != STATUS_TERMINATED):
966
+ self.session_manager.update_session_status(session.id, "running")
918
967
 
919
968
  session_state = self.track_session_stats(session, effective_status)
920
969
  session_state.current_activity = activity
@@ -981,8 +1030,10 @@ class MonitorDaemon:
981
1030
  self._enforce_oversight_timeouts(sessions)
982
1031
 
983
1032
  # Auto-archive "done" agents after 1 hour (#244)
1033
+ # Count untracked tmux windows every 2 minutes (#344)
984
1034
  if self.state.loop_count % 60 == 0:
985
1035
  self._auto_archive_done_agents(sessions)
1036
+ self.state.untracked_window_count = self._count_untracked_windows(sessions)
986
1037
 
987
1038
  # Log summary
988
1039
  green = sum(1 for s in session_states if s.current_status == STATUS_RUNNING)
@@ -174,6 +174,9 @@ class MonitorDaemonState:
174
174
  relay_last_push: Optional[str] = None # ISO timestamp of last successful push
175
175
  relay_last_status: str = "disabled" # "ok", "error", "disabled"
176
176
 
177
+ # Untracked tmux windows (#344)
178
+ untracked_window_count: int = 0
179
+
177
180
  def to_dict(self) -> dict:
178
181
  """Convert to dictionary for JSON serialization."""
179
182
  return dataclasses.asdict(self)
@@ -425,6 +425,7 @@ class TUIPreferences:
425
425
  summary_content_mode: str = "ai_short" # ai_short, ai_long, orders, annotation, heartbeat (#98, #171)
426
426
  baseline_minutes: int = 60 # 0=now (instantaneous), 15/30/.../180 = minutes back for mean spin
427
427
  monochrome: bool = False # B&W mode for terminals with ANSI issues (#138)
428
+ emoji_free: bool = False # ASCII fallbacks for terminals without emoji (#315)
428
429
  show_cost: bool = False # Show $ cost instead of token counts
429
430
  timeline_hours: float = 3.0 # 1, 3, 6, 12, 24 — timeline scope (#191)
430
431
  notifications: str = "off" # "off", "sound", "banner", "both" — macOS notifications (#235)
@@ -435,6 +436,8 @@ class TUIPreferences:
435
436
  column_config: dict = field(default_factory=dict)
436
437
  # Show abbreviated column headers above summary lines
437
438
  show_column_headers: bool = False
439
+ # Sister instances hidden from agent list (#323)
440
+ disabled_sisters: Set[str] = field(default_factory=set)
438
441
 
439
442
  @classmethod
440
443
  def load(cls, session: str) -> "TUIPreferences":
@@ -470,12 +473,14 @@ class TUIPreferences:
470
473
  summary_content_mode=data.get("summary_content_mode", "ai_short"),
471
474
  baseline_minutes=data.get("baseline_minutes", 0),
472
475
  monochrome=data.get("monochrome", False),
476
+ emoji_free=data.get("emoji_free", False),
473
477
  show_cost=data.get("show_cost", False),
474
478
  visited_stalled_agents=set(data.get("visited_stalled_agents", [])),
475
479
  column_config=data.get("column_config", {}),
476
480
  show_column_headers=data.get("show_column_headers", False),
477
481
  timeline_hours=data.get("timeline_hours", 3.0),
478
482
  notifications=data.get("notifications", "off"),
483
+ disabled_sisters=set(data.get("disabled_sisters", [])),
479
484
  )
480
485
  except (json.JSONDecodeError, IOError):
481
486
  return cls()
@@ -502,12 +507,14 @@ class TUIPreferences:
502
507
  "summary_content_mode": self.summary_content_mode,
503
508
  "baseline_minutes": self.baseline_minutes,
504
509
  "monochrome": self.monochrome,
510
+ "emoji_free": self.emoji_free,
505
511
  "show_cost": self.show_cost,
506
512
  "visited_stalled_agents": list(self.visited_stalled_agents),
507
513
  "column_config": self.column_config,
508
514
  "show_column_headers": self.show_column_headers,
509
515
  "timeline_hours": self.timeline_hours,
510
516
  "notifications": self.notifications,
517
+ "disabled_sisters": sorted(self.disabled_sisters),
511
518
  }, f, indent=2)
512
519
  except (IOError, OSError):
513
520
  pass # Best effort
@@ -93,9 +93,86 @@ STATUS_EMOJIS = {
93
93
  }
94
94
 
95
95
 
96
- def get_status_emoji(status: str) -> str:
96
+ # ASCII fallbacks for all emoji used in the TUI (#315)
97
+ # Used when emoji_free mode is active (terminals without emoji font support).
98
+ EMOJI_ASCII = {
99
+ # Status indicators
100
+ "🟢": "[R]",
101
+ "🔴": "[W]",
102
+ "⚫": "[X]",
103
+ "💤": "[Z]",
104
+ "💚": "[H]",
105
+ "🟠": "[A]",
106
+ "💛": "[h]",
107
+ "🟣": "[E]",
108
+ "☑️": "[D]",
109
+ "👁️": "[O]",
110
+ "🟡": "[S]",
111
+ "⚪": "[?]",
112
+ # Tool indicators
113
+ "🖥️": "Sh",
114
+ "📖": "Rd",
115
+ "✏️": "Wr",
116
+ "🔧": "Ed",
117
+ "🔍": "Gl",
118
+ "🔎": "Gr",
119
+ "🌐": "Wf",
120
+ "🕵️": "Ws",
121
+ "🧵": "Tk",
122
+ "📓": "Nb",
123
+ "📋": "Cb",
124
+ "📝": "Tw",
125
+ "🔹": "--",
126
+ # Permission modes
127
+ "🔥": "B!",
128
+ "🏃": "P>",
129
+ "👮": "N:",
130
+ # Activity/metrics
131
+ "🔔": "(!)",
132
+ "⏰": "AL",
133
+ "📚": "CW",
134
+ "💓": "<3",
135
+ "💰": "$$",
136
+ "⏳": "~~",
137
+ "🤖": "Ro",
138
+ "👤": "Hu",
139
+ "🤿": "Su",
140
+ "🐚": "Bg",
141
+ "👶": "Ch",
142
+ "🤝": "Tm",
143
+ "🕐": "Tc",
144
+ # Content modes
145
+ "💬": "Sm",
146
+ "🎯": "SO",
147
+ # Presence states
148
+ "⏻": "Pw",
149
+ "🔒": "Lk",
150
+ "🧘": "Id",
151
+ "🚶": "Ac",
152
+ # Value arrows
153
+ "⏫️": "^^",
154
+ "⏬️": "vv",
155
+ "⏹️": "==",
156
+ # Misc
157
+ "⚠": "!W",
158
+ "➖": "--",
159
+ "✓": "ok",
160
+ "▼": "v ",
161
+ "▶": "> ",
162
+ }
163
+
164
+
165
+ def emoji_or_ascii(char: str, emoji_free: bool) -> str:
166
+ """Return ASCII fallback if emoji_free mode is active, else the emoji."""
167
+ if emoji_free:
168
+ return EMOJI_ASCII.get(char, char)
169
+ return char
170
+
171
+
172
+ def get_status_emoji(status: str, emoji_free: bool = False) -> str:
97
173
  """Get emoji for an agent status."""
98
- return STATUS_EMOJIS.get(status, "⚪")
174
+ e = STATUS_EMOJIS.get(status, "⚪")
175
+ return emoji_or_ascii(e, emoji_free)
99
176
 
100
177
 
101
178
  # =============================================================================
@@ -143,9 +220,10 @@ STATUS_SYMBOLS = {
143
220
  }
144
221
 
145
222
 
146
- def get_status_symbol(status: str) -> Tuple[str, str]:
223
+ def get_status_symbol(status: str, emoji_free: bool = False) -> Tuple[str, str]:
147
224
  """Get (emoji, color) tuple for an agent status."""
148
- return STATUS_SYMBOLS.get(status, ("⚪", "dim"))
225
+ symbol, color = STATUS_SYMBOLS.get(status, ("⚪", "dim"))
226
+ return (emoji_or_ascii(symbol, emoji_free), color)
149
227
 
150
228
 
151
229
  # =============================================================================
@@ -49,14 +49,16 @@ TOOL_EMOJI_DEFAULT = "🔹" # Fallback for unknown tools
49
49
  MAX_TOOL_EMOJI = 10 # Configurable cap
50
50
 
51
51
 
52
- def _tool_emojis(allowed_tools: Optional[str], max_n: int = MAX_TOOL_EMOJI) -> str:
52
+ def _tool_emojis(allowed_tools: Optional[str], max_n: int = MAX_TOOL_EMOJI, emoji_free: bool = False) -> str:
53
53
  """Convert comma-separated tool names to emoji string."""
54
54
  if not allowed_tools:
55
55
  return ""
56
+ from .status_constants import emoji_or_ascii
56
57
  tools = [t.strip() for t in allowed_tools.split(",") if t.strip()]
57
- emojis = [TOOL_EMOJI.get(t, TOOL_EMOJI_DEFAULT) for t in tools[:max_n]]
58
+ emojis = [emoji_or_ascii(TOOL_EMOJI.get(t, TOOL_EMOJI_DEFAULT), emoji_free) for t in tools[:max_n]]
59
+ sep = " " if emoji_free else ""
58
60
  suffix = "…" if len(tools) > max_n else ""
59
- return "".join(emojis) + suffix
61
+ return sep.join(emojis) + suffix
60
62
 
61
63
 
62
64
  # ---------------------------------------------------------------------------
@@ -93,6 +95,7 @@ class ColumnContext:
93
95
  status_color: str
94
96
  bg: str # background style suffix, e.g. " on #0d2137" or ""
95
97
  monochrome: bool
98
+ emoji_free: bool
96
99
  summary_detail: str
97
100
  show_cost: bool
98
101
  any_has_budget: bool # True if any agent has a cost budget (#173)
@@ -153,8 +156,15 @@ class ColumnContext:
153
156
  local_hostname: str = ""
154
157
 
155
158
  def mono(self, colored: str, simple: str = "bold") -> str:
156
- """Return simplified style when monochrome is enabled."""
157
- return simple if self.monochrome else colored
159
+ """Return colored style (monochrome only applies to preview pane, not summaries)."""
160
+ return colored
161
+
162
+ def e(self, char: str) -> str:
163
+ """Return ASCII fallback if emoji_free mode is active (#315)."""
164
+ if self.emoji_free:
165
+ from .status_constants import EMOJI_ASCII
166
+ return EMOJI_ASCII.get(char, char)
167
+ return char
158
168
 
159
169
 
160
170
  # ---------------------------------------------------------------------------
@@ -251,7 +261,7 @@ def render_status_symbol(ctx: ColumnContext) -> ColumnOutput:
251
261
 
252
262
  def render_unvisited_alert(ctx: ColumnContext) -> ColumnOutput:
253
263
  if ctx.is_unvisited_stalled:
254
- return [("🔔", ctx.mono(f"bold blink red{ctx.bg}", "bold"))]
264
+ return [(ctx.e("🔔"), ctx.mono(f"bold blink red{ctx.bg}", "bold"))]
255
265
  else:
256
266
  return [(" ", ctx.mono(f"dim{ctx.bg}", "dim"))]
257
267
 
@@ -278,7 +288,7 @@ def render_sleep_countdown(ctx: ColumnContext) -> ColumnOutput:
278
288
  """
279
289
  if ctx.sleep_wake_estimate is not None:
280
290
  remaining = max(0, (ctx.sleep_wake_estimate - datetime.now()).total_seconds())
281
- return [(f" ⏰{format_duration(remaining):>5} ", ctx.mono(f"yellow{ctx.bg}", "bold"))]
291
+ return [(f" {ctx.e('')}{format_duration(remaining):>5} ", ctx.mono(f"yellow{ctx.bg}", "bold"))]
282
292
  return None
283
293
 
284
294
 
@@ -440,21 +450,21 @@ render_median_work_time = _make_simple_render("median_work", format_duration, "
440
450
  def render_subagent_count(ctx: ColumnContext) -> ColumnOutput:
441
451
  count = ctx.live_subagent_count
442
452
  style = ctx.mono(f"bold purple{ctx.bg}", "bold") if count > 0 else ctx.mono(f"dim{ctx.bg}", "dim")
443
- return [(f" 🤿{count:>2}", style)]
453
+ return [(f" {ctx.e('🤿')}{count:>2}", style)]
444
454
 
445
455
 
446
456
  def render_bash_count(ctx: ColumnContext) -> ColumnOutput:
447
457
  count = ctx.background_bash_count
448
458
  style = ctx.mono(f"bold yellow{ctx.bg}", "bold") if count > 0 else ctx.mono(f"dim{ctx.bg}", "dim")
449
- return [(f" 🐚{count:>2}", style)]
459
+ return [(f" {ctx.e('🐚')}{count:>2}", style)]
450
460
 
451
461
 
452
462
  def render_child_count(ctx: ColumnContext) -> ColumnOutput:
453
463
  count = ctx.child_count
454
464
  if count == 0:
455
- return [(" 👶 0", ctx.mono(f"dim{ctx.bg}", "dim"))]
465
+ return [(f" {ctx.e('👶')} 0", ctx.mono(f"dim{ctx.bg}", "dim"))]
456
466
  style = ctx.mono(f"bold cyan{ctx.bg}", "bold")
457
- return [(f" 👶{count:>2}", style)]
467
+ return [(f" {ctx.e('👶')}{count:>2}", style)]
458
468
 
459
469
 
460
470
  render_permission_mode = _make_simple_render("perm_emoji", format_str=" {v}", colored_style="bold white")
@@ -462,7 +472,7 @@ render_permission_mode = _make_simple_render("perm_emoji", format_str=" {v}", co
462
472
 
463
473
  def render_agent_teams(ctx: ColumnContext) -> ColumnOutput:
464
474
  if ctx.session.agent_teams:
465
- return [(" 🤝", ctx.mono(f"bold cyan{ctx.bg}", "bold"))]
475
+ return [(f" {ctx.e('🤝')}", ctx.mono(f"bold cyan{ctx.bg}", "bold"))]
466
476
  return None
467
477
 
468
478
 
@@ -473,7 +483,7 @@ def render_teams_plain(ctx: ColumnContext) -> Optional[str]:
473
483
 
474
484
 
475
485
  def render_allowed_tools(ctx: ColumnContext) -> ColumnOutput:
476
- emojis = _tool_emojis(ctx.session.allowed_tools)
486
+ emojis = _tool_emojis(ctx.session.allowed_tools, emoji_free=ctx.emoji_free)
477
487
  if not emojis:
478
488
  return None
479
489
  return [(f" {emojis}", ctx.mono(f"white{ctx.bg}", ""))]
@@ -481,7 +491,7 @@ def render_allowed_tools(ctx: ColumnContext) -> ColumnOutput:
481
491
 
482
492
  def render_time_context(ctx: ColumnContext) -> ColumnOutput:
483
493
  if ctx.session.time_context_enabled:
484
- return [(" 🕐", ctx.mono(f"bold white{ctx.bg}", "bold"))]
494
+ return [(f" {ctx.e('🕐')}", ctx.mono(f"bold white{ctx.bg}", "bold"))]
485
495
  else:
486
496
  return [(" ·", ctx.mono(f"dim{ctx.bg}", "dim"))]
487
497
 
@@ -489,24 +499,26 @@ def render_time_context(ctx: ColumnContext) -> ColumnOutput:
489
499
  def render_human_count(ctx: ColumnContext) -> ColumnOutput:
490
500
  if ctx.claude_stats is not None:
491
501
  human_count = max(0, ctx.claude_stats.interaction_count - ctx.stats.steers_count)
492
- return [(f" 👤{human_count:>3}", ctx.mono(f"bold yellow{ctx.bg}", "bold"))]
502
+ return [(f" {ctx.e('👤')}{human_count:>3}", ctx.mono(f"bold yellow{ctx.bg}", "bold"))]
493
503
  else:
494
- return [(" 👤 -", ctx.mono(f"dim yellow{ctx.bg}", "dim"))]
504
+ return [(f" {ctx.e('👤')} -", ctx.mono(f"dim yellow{ctx.bg}", "dim"))]
495
505
 
496
506
 
497
- render_robot_count = _make_simple_render("stats.steers_count", format_str=" 🤖{v:>3}", colored_style="bold cyan")
507
+ def render_robot_count(ctx: ColumnContext) -> ColumnOutput:
508
+ v = ctx.stats.steers_count
509
+ return [(f" {ctx.e('🤖')}{v:>3}", ctx.mono(f"bold cyan{ctx.bg}", "bold"))]
498
510
 
499
511
 
500
512
  def render_standing_orders(ctx: ColumnContext) -> ColumnOutput:
501
513
  s = ctx.session
502
514
  if s.standing_instructions:
503
515
  if s.standing_orders_complete:
504
- return [(" ✓", ctx.mono(f"bold green{ctx.bg}", "bold"))]
516
+ return [(f" {ctx.e('')}", ctx.mono(f"bold green{ctx.bg}", "bold"))]
505
517
  elif s.standing_instructions_preset:
506
518
  preset_display = f" {s.standing_instructions_preset[:8]}"
507
519
  return [(preset_display, ctx.mono(f"bold cyan{ctx.bg}", "bold"))]
508
520
  else:
509
- return [(" 📋", ctx.mono(f"bold yellow{ctx.bg}", "bold"))]
521
+ return [(f" {ctx.e('📋')}", ctx.mono(f"bold yellow{ctx.bg}", "bold"))]
510
522
  else:
511
523
  return [(" ➖", ctx.mono(f"bold dim{ctx.bg}", "dim"))]
512
524
 
@@ -525,24 +537,26 @@ def render_oversight_countdown(ctx: ColumnContext) -> ColumnOutput:
525
537
 
526
538
  deadline_str = ctx.oversight_deadline
527
539
  if not deadline_str:
528
- return [(" ⏳ --:--", ctx.mono(f"yellow{ctx.bg}", "dim"))]
540
+ hg = ctx.e("")
541
+ return [(f" {hg} --:--", ctx.mono(f"yellow{ctx.bg}", "dim"))]
529
542
 
530
543
  try:
544
+ hg = ctx.e("⏳")
531
545
  deadline = datetime.fromisoformat(deadline_str)
532
546
  remaining = (deadline - datetime.now()).total_seconds()
533
547
  if remaining <= 0:
534
- return [(" 0s ", ctx.mono(f"bold blink red{ctx.bg}", "bold"))]
548
+ return [(f" {hg} 0s ", ctx.mono(f"bold blink red{ctx.bg}", "bold"))]
535
549
 
536
550
  if remaining < 60:
537
- text = f" {remaining:>3.0f}s"
551
+ text = f" {hg} {remaining:>3.0f}s"
538
552
  elif remaining < 3600:
539
553
  mins = int(remaining // 60)
540
554
  secs = int(remaining % 60)
541
- text = f" {mins:>2}m{secs:02d}s"
555
+ text = f" {hg}{mins:>2}m{secs:02d}s"
542
556
  else:
543
557
  hrs = int(remaining // 3600)
544
558
  mins = int((remaining % 3600) // 60)
545
- text = f" {hrs:>2}h{mins:02d}m"
559
+ text = f" {hg}{hrs:>2}h{mins:02d}m"
546
560
 
547
561
  if remaining < 30:
548
562
  style = ctx.mono(f"bold blink red{ctx.bg}", "bold")
@@ -552,14 +566,15 @@ def render_oversight_countdown(ctx: ColumnContext) -> ColumnOutput:
552
566
  style = ctx.mono(f"bold yellow{ctx.bg}", "bold")
553
567
  return [(text, style)]
554
568
  except (ValueError, TypeError):
555
- return [(" --:--", ctx.mono(f"dim{ctx.bg}", "dim"))]
569
+ return [(f" {hg} --:--", ctx.mono(f"dim{ctx.bg}", "dim"))]
556
570
 
557
571
 
558
572
  def render_heartbeat(ctx: ColumnContext) -> ColumnOutput:
559
573
  s = ctx.session
574
+ hb = ctx.e("💓")
560
575
  if s.heartbeat_enabled and not s.heartbeat_paused:
561
576
  freq_str = format_duration(s.heartbeat_frequency_seconds)
562
- segments = [(f" 💓{freq_str:>5}", ctx.mono(f"bold magenta{ctx.bg}", "bold"))]
577
+ segments = [(f" {hb}{freq_str:>5}", ctx.mono(f"bold magenta{ctx.bg}", "bold"))]
563
578
  # Next heartbeat time in 24hr format
564
579
  next_time_str = None
565
580
  if s.last_heartbeat_time:
@@ -584,24 +599,24 @@ def render_heartbeat(ctx: ColumnContext) -> ColumnOutput:
584
599
  elif s.heartbeat_enabled and s.heartbeat_paused:
585
600
  freq_str = format_duration(s.heartbeat_frequency_seconds)
586
601
  return [
587
- (f" 💓{freq_str:>5}", ctx.mono(f"dim{ctx.bg}", "dim")),
602
+ (f" {hb}{freq_str:>5}", ctx.mono(f"dim{ctx.bg}", "dim")),
588
603
  (" ⏸ ", ctx.mono(f"bold yellow{ctx.bg}", "bold")),
589
604
  ]
590
605
  else:
591
- return [(" 💓 - @--:--", ctx.mono(f"dim{ctx.bg}", "dim"))]
606
+ return [(f" {hb} - @--:--", ctx.mono(f"dim{ctx.bg}", "dim"))]
592
607
 
593
608
 
594
609
  def render_agent_value(ctx: ColumnContext) -> ColumnOutput:
595
610
  s = ctx.session
596
611
  if ctx.summary_detail in ("full", "high"):
597
- return [(f" 💰{s.agent_value:>4}", ctx.mono(f"bold magenta{ctx.bg}", "bold"))]
612
+ return [(f" {ctx.e('💰')}{s.agent_value:>4}", ctx.mono(f"bold magenta{ctx.bg}", "bold"))]
598
613
  else:
599
614
  if s.agent_value > 1000:
600
- return [(" ⏫️", ctx.mono(f"bold red{ctx.bg}", "bold"))]
615
+ return [(f" {ctx.e('⏫️')}", ctx.mono(f"bold red{ctx.bg}", "bold"))]
601
616
  elif s.agent_value < 1000:
602
- return [(" ⏬️", ctx.mono(f"bold blue{ctx.bg}", "bold"))]
617
+ return [(f" {ctx.e('⏬️')}", ctx.mono(f"bold blue{ctx.bg}", "bold"))]
603
618
  else:
604
- return [(" ⏹️ ", ctx.mono(f"dim{ctx.bg}", "dim"))]
619
+ return [(f" {ctx.e('⏹️')} ", ctx.mono(f"dim{ctx.bg}", "dim"))]
605
620
 
606
621
 
607
622
  # ---------------------------------------------------------------------------
@@ -901,14 +916,15 @@ def build_cli_context(
901
916
  any_has_budget: bool = False, child_count: int = 0, any_is_sleeping: bool = False,
902
917
  any_has_oversight_timeout: bool = False, oversight_deadline: Optional[str] = None,
903
918
  pr_number: Optional[int] = None, any_has_pr: bool = False,
904
- monochrome: bool = True, summary_detail: str = "full",
919
+ monochrome: bool = True, emoji_free: bool = False, summary_detail: str = "full",
905
920
  has_sisters: bool = False, local_hostname: str = "",
906
921
  max_name_width: int = 16, max_repo_width: int = 10,
907
922
  max_branch_width: int = 10, all_names_match_repos: bool = False,
908
923
  subtree_cost_usd: float = 0.0, any_has_subtree_cost: bool = False,
909
924
  ) -> ColumnContext:
910
925
  """Build a ColumnContext from CLI data (no TUI widget needed)."""
911
- status_symbol, _ = get_status_symbol(status)
926
+ from .status_constants import emoji_or_ascii
927
+ status_symbol, _ = get_status_symbol(status, emoji_free=emoji_free)
912
928
  uptime = calculate_uptime(session.start_time) if session.start_time else "-"
913
929
  green_time, non_green_time, sleep_time = get_current_state_times(
914
930
  stats, is_asleep=session.is_asleep
@@ -917,11 +933,11 @@ def build_cli_context(
917
933
 
918
934
  # Permissiveness mode emoji
919
935
  if session.permissiveness_mode == "bypass":
920
- perm_emoji = "🔥"
936
+ perm_emoji = emoji_or_ascii("🔥", emoji_free)
921
937
  elif session.permissiveness_mode == "permissive":
922
- perm_emoji = "🏃"
938
+ perm_emoji = emoji_or_ascii("🏃", emoji_free)
923
939
  else:
924
- perm_emoji = "👮"
940
+ perm_emoji = emoji_or_ascii("👮", emoji_free)
925
941
 
926
942
  # Parse state_since for time-in-state
927
943
  status_changed_at = None
@@ -947,6 +963,7 @@ def build_cli_context(
947
963
  status_color="bold",
948
964
  bg="",
949
965
  monochrome=monochrome,
966
+ emoji_free=emoji_free,
950
967
  summary_detail=summary_detail,
951
968
  show_cost=True,
952
969
  any_has_budget=any_has_budget,
@@ -146,6 +146,17 @@ class TmuxManager:
146
146
  if rest:
147
147
  pane.send_keys(rest, enter=False)
148
148
  time.sleep(0.1)
149
+ elif keys.startswith('/') and len(keys) > 1:
150
+ # Special handling for slash commands (#307)
151
+ # Claude Code shows a command menu when / is typed;
152
+ # send / separately so the menu has time to appear
153
+ # before the rest of the command and Enter arrive.
154
+ pane.send_keys('/', enter=False)
155
+ time.sleep(0.3)
156
+ rest = keys[1:]
157
+ if rest:
158
+ pane.send_keys(rest, enter=False)
159
+ time.sleep(0.15)
149
160
  else:
150
161
  pane.send_keys(keys, enter=False)
151
162
  # Small delay for Claude Code to process text