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.
- overcode/cli/__init__.py +3 -0
- overcode/cli/agent.py +91 -30
- overcode/cli/budget.py +41 -0
- overcode/cli/daemon.py +13 -4
- overcode/cli/focal.py +76 -0
- overcode/cli/jobs.py +1 -1
- overcode/cli/monitoring.py +0 -1
- overcode/cli/parallelism.py +115 -0
- overcode/cli/split.py +26 -7
- overcode/cli/tags.py +121 -0
- overcode/config.py +89 -0
- overcode/follow_mode.py +12 -2
- overcode/history_reader.py +176 -12
- overcode/hook_handler.py +227 -3
- overcode/hook_status_detector.py +316 -1
- overcode/implementations.py +38 -2
- overcode/launcher.py +61 -6
- overcode/mocks.py +8 -0
- overcode/monitor_daemon.py +134 -9
- overcode/monitor_daemon_state.py +20 -0
- overcode/pricing.py +25 -6
- overcode/process_resources.py +114 -0
- overcode/protocols.py +13 -0
- overcode/sandbox_detect.py +98 -0
- overcode/session_manager.py +211 -0
- overcode/settings.py +178 -6
- overcode/sister_poller.py +11 -0
- overcode/status_constants.py +176 -3
- overcode/status_detector_factory.py +31 -1
- overcode/status_patterns.py +54 -10
- overcode/summary_columns.py +365 -21
- overcode/summary_groups.py +19 -64
- overcode/supervisor_daemon.py +24 -3
- overcode/tmux_manager.py +10 -2
- overcode/tmux_utils.py +34 -0
- overcode/tui.py +452 -27
- overcode/tui.tcss +34 -0
- overcode/tui_actions/input.py +56 -16
- overcode/tui_actions/session.py +27 -12
- overcode/tui_helpers.py +46 -0
- overcode/tui_logic.py +135 -0
- overcode/tui_widgets/__init__.py +5 -0
- overcode/tui_widgets/daemon_status_bar.py +135 -12
- overcode/tui_widgets/help_overlay.py +9 -6
- overcode/tui_widgets/jump_modal.py +163 -0
- overcode/tui_widgets/new_agent_modal.py +16 -0
- overcode/tui_widgets/passthru_config_modal.py +138 -0
- overcode/tui_widgets/preview_pane.py +15 -2
- overcode/tui_widgets/session_summary.py +76 -6
- overcode/tui_widgets/summary_config_modal.py +5 -1
- overcode/usage_monitor.py +22 -5
- overcode/web_api.py +16 -1
- overcode/web_control_api.py +1 -1
- overcode/web_server.py +1 -1
- {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/METADATA +16 -1
- {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/RECORD +60 -53
- {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/WHEEL +0 -0
- {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/entry_points.txt +0 -0
- {overcode-0.4.0.dist-info → overcode-0.4.2.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
68
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
#
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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=
|
|
316
|
-
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
|
|
334
|
-
rprint(f" Wrapper: {
|
|
335
|
-
if
|
|
336
|
-
rprint(f" 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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
overcode/cli/monitoring.py
CHANGED
|
@@ -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'"
|
|
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
|
-
|
|
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
|
-
|
|
924
|
+
from ..tmux_utils import _build_tmux_cmd
|
|
925
|
+
_cmd = [*_build_tmux_cmd(), "attach-session", "-t", oc_session]
|
|
926
|
+
os.execvp("tmux", _cmd)
|