overcode 0.4.0__py3-none-any.whl → 0.4.2__py3-none-any.whl

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 (60) hide show
  1. overcode/cli/__init__.py +3 -0
  2. overcode/cli/agent.py +91 -30
  3. overcode/cli/budget.py +41 -0
  4. overcode/cli/daemon.py +13 -4
  5. overcode/cli/focal.py +76 -0
  6. overcode/cli/jobs.py +1 -1
  7. overcode/cli/monitoring.py +0 -1
  8. overcode/cli/parallelism.py +115 -0
  9. overcode/cli/split.py +26 -7
  10. overcode/cli/tags.py +121 -0
  11. overcode/config.py +89 -0
  12. overcode/follow_mode.py +12 -2
  13. overcode/history_reader.py +176 -12
  14. overcode/hook_handler.py +227 -3
  15. overcode/hook_status_detector.py +316 -1
  16. overcode/implementations.py +38 -2
  17. overcode/launcher.py +61 -6
  18. overcode/mocks.py +8 -0
  19. overcode/monitor_daemon.py +134 -9
  20. overcode/monitor_daemon_state.py +20 -0
  21. overcode/pricing.py +25 -6
  22. overcode/process_resources.py +114 -0
  23. overcode/protocols.py +13 -0
  24. overcode/sandbox_detect.py +98 -0
  25. overcode/session_manager.py +211 -0
  26. overcode/settings.py +178 -6
  27. overcode/sister_poller.py +11 -0
  28. overcode/status_constants.py +176 -3
  29. overcode/status_detector_factory.py +31 -1
  30. overcode/status_patterns.py +54 -10
  31. overcode/summary_columns.py +365 -21
  32. overcode/summary_groups.py +19 -64
  33. overcode/supervisor_daemon.py +24 -3
  34. overcode/tmux_manager.py +10 -2
  35. overcode/tmux_utils.py +34 -0
  36. overcode/tui.py +452 -27
  37. overcode/tui.tcss +34 -0
  38. overcode/tui_actions/input.py +56 -16
  39. overcode/tui_actions/session.py +27 -12
  40. overcode/tui_helpers.py +46 -0
  41. overcode/tui_logic.py +135 -0
  42. overcode/tui_widgets/__init__.py +5 -0
  43. overcode/tui_widgets/daemon_status_bar.py +135 -12
  44. overcode/tui_widgets/help_overlay.py +9 -6
  45. overcode/tui_widgets/jump_modal.py +163 -0
  46. overcode/tui_widgets/new_agent_modal.py +16 -0
  47. overcode/tui_widgets/passthru_config_modal.py +138 -0
  48. overcode/tui_widgets/preview_pane.py +15 -2
  49. overcode/tui_widgets/session_summary.py +76 -6
  50. overcode/tui_widgets/summary_config_modal.py +5 -1
  51. overcode/usage_monitor.py +22 -5
  52. overcode/web_api.py +16 -1
  53. overcode/web_control_api.py +1 -1
  54. overcode/web_server.py +1 -1
  55. {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/METADATA +16 -1
  56. {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/RECORD +60 -53
  57. {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/WHEEL +0 -0
  58. {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/entry_points.txt +0 -0
  59. {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/licenses/LICENSE +0 -0
  60. {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/top_level.txt +0 -0
overcode/cli/__init__.py CHANGED
@@ -25,6 +25,9 @@ from . import split # noqa: F401
25
25
  from . import jobs # noqa: F401
26
26
  from . import wrappers # noqa: F401
27
27
  from . import doctor # noqa: F401
28
+ from . import parallelism # noqa: F401
29
+ from . import tags # noqa: F401
30
+ from . import focal # noqa: F401
28
31
 
29
32
 
30
33
  def main():
overcode/cli/agent.py CHANGED
@@ -64,11 +64,20 @@ def _check_skill_staleness() -> None:
64
64
  def _gather_session_stats(sess, pane_content_raw: str) -> dict:
65
65
  """Gather claude_stats, git_diff, bg_bash_count, live_sub_count for a session."""
66
66
  from ..history_reader import get_session_stats
67
- from ..status_patterns import extract_background_bash_count, extract_live_subagent_count
68
- from ..tui_helpers import get_git_diff_stats
67
+ from ..status_patterns import (
68
+ extract_background_bash_count,
69
+ extract_live_subagent_count,
70
+ extract_auto_accept_mode,
71
+ )
72
+ from ..tui_helpers import (
73
+ get_git_diff_stats,
74
+ get_git_untracked_count,
75
+ effective_git_directory,
76
+ )
69
77
 
70
78
  bg_bash_count = extract_background_bash_count(pane_content_raw) if pane_content_raw else 0
71
79
  live_sub_count = extract_live_subagent_count(pane_content_raw) if pane_content_raw else 0
80
+ auto_accept = extract_auto_accept_mode(pane_content_raw) if pane_content_raw else False
72
81
 
73
82
  claude_stats = None
74
83
  try:
@@ -79,17 +88,22 @@ def _gather_session_stats(sess, pane_content_raw: str) -> dict:
79
88
  pass
80
89
 
81
90
  git_diff = None
91
+ git_untracked = None
82
92
  try:
83
- if sess.start_directory:
84
- git_diff = get_git_diff_stats(sess.start_directory)
93
+ _gdir = effective_git_directory(sess)
94
+ if _gdir:
95
+ git_diff = get_git_diff_stats(_gdir)
96
+ git_untracked = get_git_untracked_count(_gdir)
85
97
  except Exception:
86
98
  pass
87
99
 
88
100
  return {
89
101
  "claude_stats": claude_stats,
90
102
  "git_diff": git_diff,
103
+ "git_untracked": git_untracked,
91
104
  "bg_bash_count": bg_bash_count,
92
105
  "live_sub_count": live_sub_count,
106
+ "auto_accept": auto_accept,
93
107
  }
94
108
 
95
109
 
@@ -220,6 +234,13 @@ def launch(
220
234
  Optional[str],
221
235
  typer.Option("--wrapper", "-w", help="Wrapper script (path or name from ~/.overcode/wrappers/)"),
222
236
  ] = None,
237
+ no_inherit: Annotated[
238
+ bool,
239
+ typer.Option(
240
+ "--no-inherit",
241
+ help="Don't inherit provider/model/wrapper/permissions from the parent agent (#433)",
242
+ ),
243
+ ] = False,
223
244
  sister: Annotated[
224
245
  Optional[str],
225
246
  typer.Option("--sister", "-S", help="Launch on a remote sister machine (by name from config)"),
@@ -278,22 +299,13 @@ def launch(
278
299
  # Parse oversight policy
279
300
  oversight_policy, oversight_timeout_seconds = _parse_oversight_policy(on_stuck, oversight_timeout)
280
301
 
281
- # Resolve provider: CLI flag > config default > "web"
282
- from ..config import get_new_agent_defaults
283
- agent_defaults = get_new_agent_defaults()
284
-
285
- resolved_provider = provider
286
- if resolved_provider is None:
287
- resolved_provider = agent_defaults.get("provider", "web")
288
- if resolved_provider not in ("web", "bedrock"):
289
- rprint(f"[red]Error: Invalid provider '{resolved_provider}'. Use: web, bedrock[/red]")
302
+ # Provider/wrapper resolution (CLI flag > parent settings > config
303
+ # default) happens in the launcher (#433); only validate the explicit
304
+ # flag here for a clean error message.
305
+ if provider is not None and provider not in ("web", "bedrock"):
306
+ rprint(f"[red]Error: Invalid provider '{provider}'. Use: web, bedrock[/red]")
290
307
  raise typer.Exit(code=1)
291
308
 
292
- # Resolve wrapper: CLI flag > config default > None
293
- resolved_wrapper = wrapper
294
- if resolved_wrapper is None:
295
- resolved_wrapper = agent_defaults.get("wrapper") or None
296
-
297
309
  # Default to current directory if not specified
298
310
  working_dir = directory if directory else os.getcwd()
299
311
 
@@ -312,8 +324,9 @@ def launch(
312
324
  budget_usd=budget,
313
325
  claude_agent=agent,
314
326
  model=model,
315
- provider=resolved_provider,
316
- wrapper=resolved_wrapper,
327
+ provider=provider,
328
+ wrapper=wrapper,
329
+ inherit_parent_settings=not no_inherit,
317
330
  )
318
331
 
319
332
  if result:
@@ -330,10 +343,12 @@ def launch(
330
343
  rprint(f" Agent: {agent}")
331
344
  if teams:
332
345
  rprint(" Agent teams: enabled")
333
- if resolved_wrapper:
334
- rprint(f" Wrapper: {resolved_wrapper}")
335
- if resolved_provider != "web":
336
- rprint(f" Provider: {resolved_provider}")
346
+ if result.wrapper:
347
+ rprint(f" Wrapper: {result.wrapper}")
348
+ if result.provider != "web":
349
+ rprint(f" Provider: {result.provider}")
350
+ if result.model and not model:
351
+ rprint(f" Model: {result.model} (inherited)")
337
352
  if budget is not None and budget > 0:
338
353
  rprint(f" Budget: ${budget:.2f}")
339
354
 
@@ -451,7 +466,7 @@ def list_agents(
451
466
  """
452
467
  from ..history_reader import get_session_stats
453
468
  from ..tui_helpers import (
454
- get_status_symbol, get_git_diff_stats,
469
+ get_status_symbol, get_git_diff_stats, get_git_untracked_count, effective_git_directory,
455
470
  )
456
471
  from ..monitor_daemon_state import get_monitor_daemon_state
457
472
  from ..summary_columns import build_cli_context, render_summary_cells, compute_column_widths, pad_and_join_cells, render_header_cells
@@ -578,24 +593,51 @@ def list_agents(
578
593
  claude_stats = synthesize_remote_stats(sess)
579
594
 
580
595
  git_diff = None
596
+ git_untracked = None
581
597
  if getattr(sess, 'is_remote', False):
582
598
  git_diff = getattr(sess, 'remote_git_diff', None)
599
+ git_untracked = getattr(sess, 'remote_git_untracked', None)
583
600
  else:
584
601
  try:
585
- if sess.start_directory:
586
- git_diff = get_git_diff_stats(sess.start_directory)
602
+ _gdir = effective_git_directory(sess)
603
+ if _gdir:
604
+ git_diff = get_git_diff_stats(_gdir)
605
+ git_untracked = get_git_untracked_count(_gdir)
587
606
  except Exception:
588
607
  pass
589
608
 
590
- session_data.append((sess, status, activity, claude_stats, git_diff))
609
+ session_data.append((sess, status, activity, claude_stats, git_diff, git_untracked))
591
610
 
592
611
  # Compute cross-session flags
593
- any_is_sleeping = any(st == "busy_sleeping" for _, st, _, _, _ in session_data)
612
+ any_is_sleeping = any(st == "busy_sleeping" for _, st, _, _, _, _ in session_data)
613
+
614
+ # Pull structured 2-column status detail off the detector when available
615
+ # (#TBD). Detector may be None when daemon state alone supplies status.
616
+ def _get_status_detail(sess):
617
+ if detector is None:
618
+ return None
619
+ getter = getattr(detector, "get_status_detail", None)
620
+ if getter is None:
621
+ return None
622
+ try:
623
+ return getter(sess.name)
624
+ except Exception:
625
+ return None
626
+
627
+ any_has_status_detail = any(
628
+ _get_status_detail(sd[0]) is not None for sd in session_data
629
+ )
630
+
631
+ # Heartbeat status legacy bridge (#TBD task 6)
632
+ from ..hook_status_detector import augment_with_legacy_heartbeat
633
+
634
+ def _augmented_detail(sess, status):
635
+ return augment_with_legacy_heartbeat(_get_status_detail(sess), status)
594
636
 
595
637
  # Second pass: build contexts and collect cells for auto-alignment
596
638
  all_cells = []
597
639
  activities = []
598
- for sess, status, activity, claude_stats, git_diff in session_data:
640
+ for sess, status, activity, claude_stats, git_diff, git_untracked in session_data:
599
641
  meta = tree_meta.get(sess.id)
600
642
  child_count = meta.child_count if meta else 0
601
643
 
@@ -611,8 +653,11 @@ def list_agents(
611
653
  session=sess, stats=sess.stats,
612
654
  claude_stats=claude_stats, git_diff_stats=git_diff,
613
655
  status=status, bg_bash_count=0, live_sub_count=0,
656
+ git_untracked_count=git_untracked,
614
657
  any_has_budget=any_has_budget, child_count=child_count,
615
658
  any_is_sleeping=any_is_sleeping,
659
+ status_detail=_augmented_detail(sess, status),
660
+ any_has_status_detail=any_has_status_detail,
616
661
  any_has_oversight_timeout=any_has_oversight_timeout,
617
662
  oversight_deadline=oversight_deadline,
618
663
  pr_number=getattr(sess, 'pr_number', None),
@@ -656,6 +701,18 @@ def list_agents(
656
701
  line.truncate(console.width, pad=False)
657
702
  console.print(line, no_wrap=True)
658
703
 
704
+ # Warn if the terminal is too narrow to render the data columns — the
705
+ # right side is silently truncated otherwise, hiding metrics (#459).
706
+ data_width = sum(widths)
707
+ if data_width > console.width:
708
+ next_level = "med" if detail == "full" else "low"
709
+ suggest = f" Use --{next_level} for fewer columns." if detail != "low" else ""
710
+ rprint(
711
+ f"\n[yellow]⚠ Terminal is {console.width} cols; "
712
+ f"{data_width} needed for all columns — output truncated."
713
+ f"{suggest} Widen the terminal to see the rest.[/yellow]"
714
+ )
715
+
659
716
  if terminated_count > 0:
660
717
  rprint(f"\n[dim]{terminated_count} terminated session(s). Run 'overcode cleanup' to remove.[/dim]")
661
718
 
@@ -1084,8 +1141,10 @@ def show(
1084
1141
  stats_data = _gather_session_stats(sess, pane_content_raw)
1085
1142
  claude_stats = stats_data["claude_stats"]
1086
1143
  git_diff = stats_data["git_diff"]
1144
+ git_untracked = stats_data["git_untracked"]
1087
1145
  bg_bash_count = stats_data["bg_bash_count"]
1088
1146
  live_sub_count = stats_data["live_sub_count"]
1147
+ auto_accept = stats_data["auto_accept"]
1089
1148
 
1090
1149
  # AI summaries from daemon state
1091
1150
  ai_short = ""
@@ -1104,6 +1163,8 @@ def show(
1104
1163
  status=status,
1105
1164
  bg_bash_count=bg_bash_count,
1106
1165
  live_sub_count=live_sub_count,
1166
+ git_untracked_count=git_untracked,
1167
+ auto_accept_mode=auto_accept,
1107
1168
  any_has_budget=any_has_budget,
1108
1169
  )
1109
1170
 
overcode/cli/budget.py CHANGED
@@ -99,6 +99,47 @@ def budget_transfer(
99
99
  raise typer.Exit(code=1)
100
100
 
101
101
 
102
+ @budget_app.command("reclaim")
103
+ def budget_reclaim(
104
+ name: Annotated[str, typer.Argument(help="Name of the child agent to reclaim from")],
105
+ session: SessionOption = "agents",
106
+ ):
107
+ """Refund a child agent's unused budget back to its parent (#432).
108
+
109
+ Use this for agents that died or crashed without posting a result. For
110
+ successful child completions the refund happens automatically when the
111
+ report is processed.
112
+
113
+ The refund is idempotent: a second call returns 0.
114
+
115
+ Examples:
116
+ overcode budget reclaim my-dead-child
117
+ """
118
+ from ..session_manager import SessionManager
119
+
120
+ manager = SessionManager()
121
+ agent = manager.get_session_by_name(name)
122
+ if not agent:
123
+ rprint(f"[red]Error: Agent '{name}' not found[/red]")
124
+ raise typer.Exit(code=1)
125
+
126
+ if not agent.parent_session_id:
127
+ rprint(f"[red]Error: '{name}' has no parent — nothing to reclaim to[/red]")
128
+ raise typer.Exit(code=1)
129
+
130
+ refunded = manager.reclaim_budget(agent.id)
131
+ if refunded is None:
132
+ rprint(f"[yellow]Nothing to reclaim from '{name}' (unlimited or missing parent)[/yellow]")
133
+ return
134
+ if refunded <= 0:
135
+ rprint(f"[yellow]'{name}' has spent its full budget — nothing to refund[/yellow]")
136
+ return
137
+
138
+ parent = manager.get_session(agent.parent_session_id)
139
+ parent_name = parent.name if parent else "(parent)"
140
+ rprint(f"[green]✓ Refunded ${refunded:.4f} from {name} to {parent_name}[/green]")
141
+
142
+
102
143
  @budget_app.command("show")
103
144
  def budget_show(
104
145
  name: Annotated[
overcode/cli/daemon.py CHANGED
@@ -101,6 +101,17 @@ def monitor_daemon_status_cmd(session: SessionOption = "agents"):
101
101
  _monitor_daemon_status(session)
102
102
 
103
103
 
104
+ def _format_loop_time_ago(last_loop_time: str) -> str:
105
+ """MonitorDaemonState timestamps are ISO strings; format_ago needs datetime."""
106
+ from datetime import datetime
107
+ from ..tui_helpers import format_ago
108
+
109
+ try:
110
+ return format_ago(datetime.fromisoformat(last_loop_time))
111
+ except (ValueError, TypeError):
112
+ return str(last_loop_time)
113
+
114
+
104
115
  def _monitor_daemon_status(session: str):
105
116
  """Internal function for showing monitor daemon status."""
106
117
  from ..monitor_daemon import is_monitor_daemon_running, get_monitor_daemon_pid
@@ -110,8 +121,7 @@ def _monitor_daemon_status(session: str):
110
121
  rprint(f"[dim]Monitor Daemon ({session}):[/dim] ○ stopped")
111
122
  state = get_monitor_daemon_state(session)
112
123
  if state and state.last_loop_time:
113
- from ..tui_helpers import format_ago
114
- rprint(f" [dim]Last active: {format_ago(state.last_loop_time)}[/dim]")
124
+ rprint(f" [dim]Last active: {_format_loop_time_ago(state.last_loop_time)}[/dim]")
115
125
  return
116
126
 
117
127
  pid = get_monitor_daemon_pid(session)
@@ -124,8 +134,7 @@ def _monitor_daemon_status(session: str):
124
134
  rprint(f" Interval: {state.current_interval}s")
125
135
  rprint(f" Sessions: {len(state.sessions)}")
126
136
  if state.last_loop_time:
127
- from ..tui_helpers import format_ago
128
- rprint(f" Last loop: {format_ago(state.last_loop_time)}")
137
+ rprint(f" Last loop: {_format_loop_time_ago(state.last_loop_time)}")
129
138
  if state.presence_available:
130
139
  rprint(f" Presence: state={state.presence_state}, idle={state.presence_idle_seconds:.0f}s")
131
140
 
overcode/cli/focal.py ADDED
@@ -0,0 +1,76 @@
1
+ """
2
+ `overcode focal-repo` — show or set the focal repo for a multi-repo agent (#170).
3
+ """
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+ from rich import print as rprint
9
+
10
+ from ._shared import app, SessionOption
11
+
12
+
13
+ @app.command("focal-repo")
14
+ def focal_repo(
15
+ name: Annotated[str, typer.Argument(help="Agent name")],
16
+ subdir: Annotated[
17
+ Optional[str],
18
+ typer.Argument(
19
+ help="Subdir to focus on. Pass `-` to clear. Omit to list candidates.",
20
+ ),
21
+ ] = None,
22
+ session: SessionOption = "agents",
23
+ ):
24
+ """Show or set the focal repo for a multi-repo workspace agent (#170).
25
+
26
+ With no subdir argument: lists the detected candidate repos and which
27
+ one (if any) is currently focal.
28
+
29
+ With a subdir: sets that subdir as the focal repo. Pass `-` to clear
30
+ the focal back to the workspace root.
31
+
32
+ For agents whose start_directory is itself a single git repo, this
33
+ command reports that there's nothing to cycle through.
34
+
35
+ Examples:
36
+ overcode focal-repo my-agent # list
37
+ overcode focal-repo my-agent backend # set focal
38
+ overcode focal-repo my-agent - # clear focal
39
+ """
40
+ from ..session_manager import SessionManager
41
+
42
+ manager = SessionManager()
43
+ agent = manager.get_session_by_name(name)
44
+ if not agent:
45
+ rprint(f"[red]Error: Agent '{name}' not found[/red]")
46
+ raise typer.Exit(code=1)
47
+
48
+ candidates = manager.detect_focal_repo_candidates(agent.start_directory)
49
+ if not candidates:
50
+ rprint(
51
+ f"[yellow]'{name}' is a single-repo workspace at "
52
+ f"{agent.start_directory or '(no start dir)'} — nothing to focus.[/yellow]"
53
+ )
54
+ return
55
+
56
+ if subdir is None:
57
+ # List mode
58
+ rprint(f"[bold]{name}[/bold] focal candidates:")
59
+ for c in candidates:
60
+ marker = "→" if c == agent.focal_repo_subdir else " "
61
+ rprint(f" {marker} {c}")
62
+ if not agent.focal_repo_subdir:
63
+ rprint(" [dim](no focal set — sampling start_directory directly)[/dim]")
64
+ return
65
+
66
+ # Set mode
67
+ if subdir == "-":
68
+ manager.set_focal_repo(agent.id, None)
69
+ rprint(f"[green]✓ Cleared focal repo for {name}[/green]")
70
+ return
71
+ try:
72
+ manager.set_focal_repo(agent.id, subdir)
73
+ except ValueError as e:
74
+ rprint(f"[red]{e}[/red]")
75
+ raise typer.Exit(code=1)
76
+ rprint(f"[green]✓ {name} focal repo: {subdir}[/green]")
overcode/cli/jobs.py CHANGED
@@ -16,7 +16,7 @@ def bash(
16
16
  name: Annotated[Optional[str], typer.Option("--name", "-n", help="Job name (auto-derived if omitted)")] = None,
17
17
  directory: Annotated[str, typer.Option("--directory", "-d", help="Working directory")] = ".",
18
18
  agent: Annotated[Optional[str], typer.Option("--agent", "-a", help="Link to an agent session by name")] = None,
19
- follow: Annotated[bool, typer.Option("--follow", "-f", help="Attach to the job's tmux window after launch")] = True,
19
+ follow: Annotated[bool, typer.Option("--follow/--no-follow", "-f", help="Attach to the job's tmux window after launch")] = True,
20
20
  ):
21
21
  """Launch a bash command as a tracked job."""
22
22
  import os
@@ -444,7 +444,6 @@ def export(
444
444
  rprint(f" Presence rows: {result['presence_rows']}")
445
445
  except ImportError as e:
446
446
  rprint(f"[red]Error:[/red] {e}")
447
- rprint("[dim]Install pyarrow: pip install pyarrow[/dim]")
448
447
  raise typer.Exit(1)
449
448
  except Exception as e:
450
449
  rprint(f"[red]Export failed:[/red] {e}")
@@ -0,0 +1,115 @@
1
+ """
2
+ `overcode parallelism` — recommend a sensible max-children cap (#365).
3
+
4
+ Computes a safe parallelism budget from system resources and current agent
5
+ load. Useful as a guard before spawning more child agents from a parent.
6
+ """
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich import print as rprint
12
+ from rich.table import Table
13
+
14
+ from ._shared import app, SessionOption
15
+
16
+
17
+ # Heuristics — kept conservative because Claude children can spike well
18
+ # above their idle footprint, and the user probably wants headroom for
19
+ # the foreground app + supervision processes.
20
+ _RESERVED_CORES = 1
21
+ _PER_CHILD_RAM_GB = 1.5
22
+ _PER_CHILD_CPU_FRACTION = 0.5 # assume each child saturates ~half a core sustained
23
+
24
+
25
+ def _system_cores() -> int:
26
+ import os
27
+ return os.cpu_count() or 1
28
+
29
+
30
+ def _system_ram_gb() -> float:
31
+ try:
32
+ import psutil # type: ignore
33
+ return psutil.virtual_memory().total / (1024 ** 3)
34
+ except Exception:
35
+ # Fallback for systems without psutil — read sysconf if available.
36
+ try:
37
+ import os
38
+ pages = os.sysconf("SC_PHYS_PAGES")
39
+ page_size = os.sysconf("SC_PAGE_SIZE")
40
+ return (pages * page_size) / (1024 ** 3)
41
+ except (AttributeError, ValueError, OSError):
42
+ return 0.0
43
+
44
+
45
+ def _recommend_cap(cores: int, ram_gb: float) -> int:
46
+ by_cpu = max(1, int((cores - _RESERVED_CORES) / _PER_CHILD_CPU_FRACTION))
47
+ by_ram = max(1, int(ram_gb / _PER_CHILD_RAM_GB)) if ram_gb > 0 else by_cpu
48
+ return min(by_cpu, by_ram)
49
+
50
+
51
+ @app.command()
52
+ def parallelism(
53
+ session: SessionOption = "agents",
54
+ json_out: Annotated[
55
+ bool,
56
+ typer.Option("--json", help="Emit recommendation as JSON for agents to consume"),
57
+ ] = False,
58
+ ):
59
+ """Recommend a max-children cap based on system resources and current load (#365).
60
+
61
+ Looks at CPU cores, total RAM, and the in-progress agents' resource
62
+ use, then prints a recommended ceiling and whether the current count
63
+ is safe. Use it as a quick sanity-check before launching more
64
+ children from a parent agent.
65
+ """
66
+ from ..launcher import ClaudeLauncher
67
+
68
+ launcher = ClaudeLauncher(session)
69
+ sessions = [
70
+ s for s in launcher.list_sessions(detect_terminated=False)
71
+ if s.status not in ("terminated", "done")
72
+ ]
73
+
74
+ cores = _system_cores()
75
+ ram_gb = _system_ram_gb()
76
+ recommended = _recommend_cap(cores, ram_gb)
77
+
78
+ current_count = len(sessions)
79
+ total_cpu_pct = sum((getattr(s, "cpu_percent", 0.0) or 0.0) for s in sessions)
80
+ total_ram_bytes = sum((getattr(s, "rss_bytes", 0) or 0) for s in sessions)
81
+ total_ram_gb = total_ram_bytes / (1024 ** 3)
82
+
83
+ headroom = max(0, recommended - current_count)
84
+ over = current_count > recommended
85
+
86
+ if json_out:
87
+ import json as _json
88
+ rprint(_json.dumps({
89
+ "cores": cores,
90
+ "ram_gb": round(ram_gb, 2),
91
+ "recommended_max_children": recommended,
92
+ "current_count": current_count,
93
+ "headroom": headroom,
94
+ "over_limit": over,
95
+ "current_total_cpu_pct": round(total_cpu_pct, 1),
96
+ "current_total_ram_gb": round(total_ram_gb, 2),
97
+ }))
98
+ return
99
+
100
+ table = Table(show_header=False, box=None, padding=(0, 1))
101
+ table.add_row("[bold]System[/bold]", f"{cores} cores · {ram_gb:.1f} GB RAM")
102
+ table.add_row("[bold]Per-child budget[/bold]",
103
+ f"≤{_PER_CHILD_CPU_FRACTION:.1f} core · ~{_PER_CHILD_RAM_GB:.1f} GB RAM")
104
+ table.add_row("[bold]Recommended max[/bold]", f"[green]{recommended}[/green] children")
105
+ table.add_row("[bold]Currently active[/bold]",
106
+ f"{current_count} agents · {total_cpu_pct:.0f}% CPU · {total_ram_gb:.1f} GB RAM")
107
+ if over:
108
+ table.add_row("[bold]Verdict[/bold]",
109
+ f"[red]Over by {current_count - recommended}[/red] — consider letting some finish before spawning more.")
110
+ elif headroom == 0:
111
+ table.add_row("[bold]Verdict[/bold]", "[yellow]At cap[/yellow] — no headroom for new children.")
112
+ else:
113
+ table.add_row("[bold]Verdict[/bold]",
114
+ f"[green]OK[/green] — room for [bold]{headroom}[/bold] more children.")
115
+ rprint(table)
overcode/cli/split.py CHANGED
@@ -32,6 +32,7 @@ from pathlib import Path
32
32
  from typing import Annotated
33
33
 
34
34
  import typer
35
+ from rich import print as rprint
35
36
 
36
37
  from ._shared import app, SessionOption
37
38
  from ..tmux_utils import get_pane_base_index
@@ -397,14 +398,30 @@ def _setup_keybindings(linked_session: str = "", toggle_key: str = "") -> None:
397
398
  "if-shell -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' "
398
399
  "'send-keys -M' 'copy-mode -e'",
399
400
  )
401
+ # Wheel-down on the bottom pane. We want to mirror wheel-up's
402
+ # 3-lines-per-tick rate while still exiting cleanly when the user
403
+ # scrolls back to the bottom.
404
+ #
405
+ # `send-keys -X -N 3 scroll-down` only works inside copy mode; out
406
+ # of copy mode it errors with "not in a mode" and tmux paints the
407
+ # status line, leaving the client looking frozen (#454). So for
408
+ # the local branch we gate on `pane_in_mode` of the linked
409
+ # session: in copy mode → scroll-down 3 lines (matches wheel-up);
410
+ # out of copy mode (already at the bottom) → NPage, a plain
411
+ # keystroke that's harmlessly delivered to the agent.
412
+ #
413
+ # SSH proxy branch keeps the unconditional NPage: we can't
414
+ # observe the remote tmux's mode from here, and NPage is safe in
415
+ # both states (page-down in remote copy mode, passthrough
416
+ # otherwise).
400
417
  _tmux(
401
418
  "bind-key", "-n", "WheelDownPane",
402
419
  "if-shell", "-F", _in_bottom,
403
- # SSH proxy: send NPage to remote (works in copy mode).
404
- # Local: send scroll-down in local copy mode.
405
420
  f'if-shell "{_ssh_check}" '
406
421
  f'"send-keys -t {linked_session} NPage" '
407
- f'"send-keys -t {linked_session} -X -N 3 scroll-down"',
422
+ f'"if-shell -t {linked_session} -F \'#{{pane_in_mode}}\''
423
+ f' \'send-keys -t {linked_session} -X -N 3 scroll-down\''
424
+ f' \'send-keys -t {linked_session} NPage\'"',
408
425
  "send-keys -M",
409
426
  )
410
427
 
@@ -548,8 +565,6 @@ def tmux_layout(
548
565
  normally elsewhere, but they do override any custom user bindings for
549
566
  the same keys. Use --uninstall to remove them.
550
567
  """
551
- from rich import print as rprint
552
-
553
568
  # --- Uninstall mode ---
554
569
  if uninstall:
555
570
  removed_anything = False
@@ -732,7 +747,9 @@ def _tmux_layout_locked(session: str, ratio: int, rprint, *, restart: bool = Fal
732
747
  monitor_cmd)
733
748
  rprint(f"[green]Attaching to existing {SPLIT_WINDOW_NAME} window (monitor restarted)...[/green]")
734
749
  time.sleep(0.2)
735
- os.execlp("tmux", "tmux", "attach-session", "-t", oc_session)
750
+ from ..tmux_utils import _build_tmux_cmd
751
+ _cmd = [*_build_tmux_cmd(), "attach-session", "-t", oc_session]
752
+ os.execvp("tmux", _cmd)
736
753
  # Always restart the monitor so code changes take effect
737
754
  _tmux("respawn-pane", "-k",
738
755
  "-t", f"{oc_session}:{SPLIT_WINDOW_NAME}.{get_pane_base_index()}",
@@ -904,4 +921,6 @@ def _tmux_layout_locked(session: str, ratio: int, rprint, *, restart: bool = Fal
904
921
  # Attach to the session (replaces this process)
905
922
  rprint(f"[green]Attaching to split layout...[/green]")
906
923
  time.sleep(0.2)
907
- os.execlp("tmux", "tmux", "attach-session", "-t", oc_session)
924
+ from ..tmux_utils import _build_tmux_cmd
925
+ _cmd = [*_build_tmux_cmd(), "attach-session", "-t", oc_session]
926
+ os.execvp("tmux", _cmd)