overcode 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {overcode-0.3.0/src/overcode.egg-info → overcode-0.3.1}/PKG-INFO +1 -1
- {overcode-0.3.0 → overcode-0.3.1}/pyproject.toml +1 -1
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/bundled_skills.py +44 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/__init__.py +1 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/_shared.py +8 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/agent.py +8 -0
- overcode-0.3.1/src/overcode/cli/jobs.py +156 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/monitoring.py +4 -1
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/split.py +8 -1
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/config.py +13 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/history_reader.py +22 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/implementations.py +37 -0
- overcode-0.3.1/src/overcode/job_launcher.py +211 -0
- overcode-0.3.1/src/overcode/job_manager.py +351 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/launcher.py +8 -1
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/mocks.py +4 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/monitor_daemon.py +13 -7
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/monitor_daemon_core.py +8 -8
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/monitor_daemon_state.py +4 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/protocols.py +8 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/session_manager.py +5 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/settings.py +69 -12
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/sister_poller.py +2 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_detector.py +7 -2
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_patterns.py +83 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/summary_columns.py +27 -6
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tmux_manager.py +16 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui.py +384 -109
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui.tcss +43 -16
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/input.py +4 -1
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/navigation.py +14 -2
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/session.py +10 -9
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/view.py +57 -46
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_render.py +6 -9
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/__init__.py +2 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/help_overlay.py +4 -6
- overcode-0.3.1/src/overcode/tui_widgets/job_summary.py +129 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/preview_pane.py +20 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/session_summary.py +14 -112
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_api.py +1 -0
- {overcode-0.3.0 → overcode-0.3.1/src/overcode.egg-info}/PKG-INFO +1 -1
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/SOURCES.txt +4 -0
- {overcode-0.3.0 → overcode-0.3.1}/LICENSE +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/MANIFEST.in +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/README.md +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/setup.cfg +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/__init__.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/agent_scanner.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/claude_config.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/claude_pid.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/__main__.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/budget.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/config.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/daemon.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/hooks.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/perms.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/sister.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/skills.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/daemon_utils.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/data_export.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/dependency_check.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/duration.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/exceptions.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/follow_mode.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/hook_handler.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/hook_status_detector.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/interfaces.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/logging_config.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/notifier.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/pid_utils.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/presence_logger.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/sister_controller.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_constants.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_detector_factory.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_history.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/summarizer_component.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/summary_groups.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/supervisor_daemon.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/supervisor_daemon_core.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/__init__.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/renderer.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/tmux_driver.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/tui_eye.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/tui_eye_skill.md +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/time_context.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tmux_utils.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/__init__.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/daemon.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_helpers.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_logic.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/command_bar.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/daemon_panel.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/status_timeline.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/usage_monitor.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web/__init__.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web/templates/analytics.html +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web/templates/dashboard.html +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_control_api.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_server.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_templates.py +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/entry_points.txt +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.3.0 → overcode-0.3.1}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -82,6 +82,26 @@ overcode send my-agent escape # Reject permission
|
|
|
82
82
|
overcode send my-agent "yes" # Send text response
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
## Jobs (Long-Running Bash Commands)
|
|
86
|
+
|
|
87
|
+
For long-running commands (10min+ \u2014 test suites, builds, deploys), use `overcode bash` to launch them as tracked jobs in a separate tmux session. This keeps the output visible and lets you monitor multiple concurrent jobs from the TUI.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Launch a job
|
|
91
|
+
overcode bash "pytest tests/ -x" --name unit-tests
|
|
92
|
+
overcode bash "npm run build" -d ~/frontend
|
|
93
|
+
overcode bash "make deploy-staging" --agent my-agent # Link to an agent
|
|
94
|
+
|
|
95
|
+
# Manage jobs
|
|
96
|
+
overcode jobs list [--all] # List running (or all) jobs
|
|
97
|
+
overcode jobs kill <name> # Kill a running job
|
|
98
|
+
overcode jobs attach <name> # Attach to job's tmux window
|
|
99
|
+
overcode jobs clear # Remove completed/failed/killed jobs
|
|
100
|
+
|
|
101
|
+
# TUI: press J to toggle jobs view, j/k to navigate, x to kill, c to clear
|
|
102
|
+
overcode monitor --jobs # Start TUI directly in jobs view
|
|
103
|
+
```
|
|
104
|
+
|
|
85
105
|
## Standing Instructions Presets
|
|
86
106
|
|
|
87
107
|
`overcode instruct <name> <preset>` \u2014 available presets: `DO_NOTHING`, `STANDARD`, `PERMISSIVE`, `CAUTIOUS`, `RESEARCH`, `CODING`, `TESTING`, `REVIEW`, `DEPLOY`, `AUTONOMOUS`, `MINIMAL`.
|
|
@@ -194,6 +214,30 @@ Pass arbitrary Claude CLI flags with `--claude-arg` (repeatable):
|
|
|
194
214
|
overcode launch -n fast --claude-arg "--model haiku" --claude-arg "--effort low" -p "Quick review"
|
|
195
215
|
```
|
|
196
216
|
|
|
217
|
+
## Long-Running Shell Commands (Jobs)
|
|
218
|
+
|
|
219
|
+
When a task involves a long-running shell command (10min+ \u2014 full test suites, builds, deploys, docker compose), use `overcode bash` instead of running it inline. This launches the command as a tracked job in a separate tmux session with its own window, so you can monitor it without blocking your agent session.
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
# Launch a test suite as a tracked job
|
|
223
|
+
overcode bash "pytest tests/ -x --timeout=600" --name full-tests
|
|
224
|
+
|
|
225
|
+
# Launch a build linked to the current agent
|
|
226
|
+
overcode bash "npm run build" --name frontend-build --agent my-agent
|
|
227
|
+
|
|
228
|
+
# Check on it later
|
|
229
|
+
overcode jobs list
|
|
230
|
+
overcode jobs attach full-tests # Attach to see live output
|
|
231
|
+
overcode jobs kill full-tests # Kill if needed
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**When to use `overcode bash` vs running inline:**
|
|
235
|
+
- Inline (`Bash` tool): Quick commands (<2 min) where you need the result to continue
|
|
236
|
+
- `overcode bash`: Long commands (10min+) \u2014 test suites, builds, deploys, docker operations
|
|
237
|
+
- `overcode launch`: When you need a full Claude Code agent with AI reasoning
|
|
238
|
+
|
|
239
|
+
Jobs are visible in the TUI jobs view (press `J`) and auto-clean after 24h (configurable via `jobs.retention_hours` in config).
|
|
240
|
+
|
|
197
241
|
## Rules
|
|
198
242
|
|
|
199
243
|
- Parent auto-detected from `OVERCODE_SESSION_NAME` \u2014 no `--parent` needed inside an overcode agent
|
|
@@ -76,6 +76,14 @@ sister_app = typer.Typer(
|
|
|
76
76
|
)
|
|
77
77
|
app.add_typer(sister_app, name="sister")
|
|
78
78
|
|
|
79
|
+
# Jobs subcommand group
|
|
80
|
+
jobs_app = typer.Typer(
|
|
81
|
+
name="jobs",
|
|
82
|
+
help="Manage overcode jobs.",
|
|
83
|
+
no_args_is_help=True,
|
|
84
|
+
)
|
|
85
|
+
app.add_typer(jobs_app, name="jobs")
|
|
86
|
+
|
|
79
87
|
# Config subcommand group
|
|
80
88
|
config_app = typer.Typer(
|
|
81
89
|
name="config",
|
|
@@ -192,6 +192,10 @@ def launch(
|
|
|
192
192
|
Optional[str],
|
|
193
193
|
typer.Option("--allowed-tools", help="Comma-separated tools for Claude (e.g. 'Bash,Read,Write,Edit')"),
|
|
194
194
|
] = None,
|
|
195
|
+
model: Annotated[
|
|
196
|
+
Optional[str],
|
|
197
|
+
typer.Option("--model", "-m", help="Claude model (e.g. sonnet, opus, haiku, or full name)"),
|
|
198
|
+
] = None,
|
|
195
199
|
claude_args: Annotated[
|
|
196
200
|
Optional[List[str]],
|
|
197
201
|
typer.Option("--claude-arg", help="Extra Claude CLI flag (repeatable, e.g. '--model haiku')"),
|
|
@@ -283,6 +287,7 @@ def launch(
|
|
|
283
287
|
agent_teams=teams,
|
|
284
288
|
budget_usd=budget,
|
|
285
289
|
claude_agent=agent,
|
|
290
|
+
model=model,
|
|
286
291
|
)
|
|
287
292
|
|
|
288
293
|
if result:
|
|
@@ -536,6 +541,9 @@ def list_agents(
|
|
|
536
541
|
claude_stats = get_session_stats(sess)
|
|
537
542
|
except Exception:
|
|
538
543
|
pass
|
|
544
|
+
if claude_stats is None and getattr(sess, 'is_remote', False):
|
|
545
|
+
from ..history_reader import synthesize_remote_stats
|
|
546
|
+
claude_stats = synthesize_remote_stats(sess)
|
|
539
547
|
|
|
540
548
|
git_diff = None
|
|
541
549
|
if getattr(sess, 'is_remote', False):
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for managing overcode jobs.
|
|
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, jobs_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def bash(
|
|
15
|
+
command: Annotated[str, typer.Argument(help="Bash command to run as a tracked job")],
|
|
16
|
+
name: Annotated[Optional[str], typer.Option("--name", "-n", help="Job name (auto-derived if omitted)")] = None,
|
|
17
|
+
directory: Annotated[str, typer.Option("--directory", "-d", help="Working directory")] = ".",
|
|
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,
|
|
20
|
+
):
|
|
21
|
+
"""Launch a bash command as a tracked job."""
|
|
22
|
+
import os
|
|
23
|
+
from ..job_launcher import JobLauncher
|
|
24
|
+
from ..session_manager import SessionManager
|
|
25
|
+
|
|
26
|
+
directory = os.path.abspath(directory)
|
|
27
|
+
|
|
28
|
+
agent_session_id = None
|
|
29
|
+
agent_name = None
|
|
30
|
+
# Auto-detect calling agent from env if --agent not specified
|
|
31
|
+
if not agent:
|
|
32
|
+
agent = os.environ.get("OVERCODE_SESSION_NAME")
|
|
33
|
+
if agent:
|
|
34
|
+
sm = SessionManager()
|
|
35
|
+
sess = sm.get_session_by_name(agent)
|
|
36
|
+
if sess:
|
|
37
|
+
agent_session_id = sess.id
|
|
38
|
+
agent_name = sess.name
|
|
39
|
+
else:
|
|
40
|
+
rprint(f"[yellow]Warning: Agent '{agent}' not found, launching without link[/yellow]")
|
|
41
|
+
|
|
42
|
+
launcher = JobLauncher()
|
|
43
|
+
job = launcher.launch(
|
|
44
|
+
command=command,
|
|
45
|
+
name=name,
|
|
46
|
+
directory=directory,
|
|
47
|
+
agent_session_id=agent_session_id,
|
|
48
|
+
agent_name=agent_name,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
rprint(f"[green]✓[/green] Job '[bold]{job.name}[/bold]' launched")
|
|
52
|
+
rprint(f" Command: {job.command}")
|
|
53
|
+
rprint(f" Window: {job.tmux_session}:{job.tmux_window}")
|
|
54
|
+
if agent_name:
|
|
55
|
+
rprint(f" Linked to agent: {agent_name}")
|
|
56
|
+
|
|
57
|
+
if follow:
|
|
58
|
+
launcher.attach(job.name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@jobs_app.command("list")
|
|
62
|
+
def list_jobs(
|
|
63
|
+
all: Annotated[bool, typer.Option("--all", "-a", help="Include completed/failed/killed jobs")] = False,
|
|
64
|
+
):
|
|
65
|
+
"""List tracked jobs."""
|
|
66
|
+
from ..job_launcher import JobLauncher
|
|
67
|
+
|
|
68
|
+
launcher = JobLauncher()
|
|
69
|
+
jobs = launcher.list_jobs(include_completed=all)
|
|
70
|
+
|
|
71
|
+
if not jobs:
|
|
72
|
+
rprint("[dim]No jobs running[/dim]" if not all else "[dim]No jobs found[/dim]")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
for job in sorted(jobs, key=lambda j: j.start_time, reverse=True):
|
|
76
|
+
status_icon = {
|
|
77
|
+
"running": "[green]●[/green]",
|
|
78
|
+
"completed": "[green]✓[/green]",
|
|
79
|
+
"failed": "[red]✗[/red]",
|
|
80
|
+
"killed": "[yellow]✗[/yellow]",
|
|
81
|
+
}.get(job.status, "?")
|
|
82
|
+
|
|
83
|
+
exit_str = ""
|
|
84
|
+
if job.exit_code is not None:
|
|
85
|
+
exit_str = f" ({job.exit_code})"
|
|
86
|
+
|
|
87
|
+
duration = ""
|
|
88
|
+
if job.start_time:
|
|
89
|
+
from datetime import datetime
|
|
90
|
+
try:
|
|
91
|
+
start = datetime.fromisoformat(job.start_time)
|
|
92
|
+
end = datetime.fromisoformat(job.end_time) if job.end_time else datetime.now()
|
|
93
|
+
dur_sec = (end - start).total_seconds()
|
|
94
|
+
mins, secs = divmod(int(dur_sec), 60)
|
|
95
|
+
duration = f"{mins}m{secs:02d}s"
|
|
96
|
+
except ValueError:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
agent_str = f" ← {job.agent_name}" if job.agent_name else ""
|
|
100
|
+
rprint(
|
|
101
|
+
f" {status_icon} {job.name:<16} {job.command:<30} {duration:>8} "
|
|
102
|
+
f"{job.status}{exit_str}{agent_str}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@jobs_app.command("kill")
|
|
107
|
+
def kill_job(
|
|
108
|
+
name: Annotated[str, typer.Argument(help="Job name to kill")],
|
|
109
|
+
):
|
|
110
|
+
"""Kill a running job."""
|
|
111
|
+
from ..job_launcher import JobLauncher
|
|
112
|
+
|
|
113
|
+
launcher = JobLauncher()
|
|
114
|
+
if launcher.kill_job(name):
|
|
115
|
+
rprint(f"[green]✓[/green] Job '[bold]{name}[/bold]' killed")
|
|
116
|
+
else:
|
|
117
|
+
rprint(f"[red]✗[/red] Could not kill job '{name}' (not found or not running)")
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@jobs_app.command("attach")
|
|
122
|
+
def attach_job(
|
|
123
|
+
name: Annotated[str, typer.Argument(help="Job name to attach to")],
|
|
124
|
+
bare: Annotated[bool, typer.Option("--bare", "-b", help="Attach with stripped tmux chrome")] = False,
|
|
125
|
+
):
|
|
126
|
+
"""Attach to a job's tmux window."""
|
|
127
|
+
from ..job_launcher import JobLauncher
|
|
128
|
+
|
|
129
|
+
launcher = JobLauncher()
|
|
130
|
+
try:
|
|
131
|
+
launcher.attach(name, bare=bare)
|
|
132
|
+
except ValueError as e:
|
|
133
|
+
rprint(f"[red]✗[/red] {e}")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@jobs_app.command("clear")
|
|
138
|
+
def clear_completed():
|
|
139
|
+
"""Remove all completed/failed/killed jobs."""
|
|
140
|
+
from ..job_manager import JobManager
|
|
141
|
+
|
|
142
|
+
manager = JobManager()
|
|
143
|
+
manager.clear_completed()
|
|
144
|
+
rprint("[green]✓[/green] Cleared completed jobs")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@jobs_app.command("_complete", hidden=True)
|
|
148
|
+
def mark_complete(
|
|
149
|
+
job_id: Annotated[str, typer.Argument(help="Job ID")],
|
|
150
|
+
exit_code: Annotated[int, typer.Argument(help="Exit code")],
|
|
151
|
+
):
|
|
152
|
+
"""Internal: mark a job as complete (called by wrapper script)."""
|
|
153
|
+
from ..job_manager import JobManager
|
|
154
|
+
|
|
155
|
+
manager = JobManager()
|
|
156
|
+
manager.mark_complete(job_id, exit_code)
|
|
@@ -273,6 +273,9 @@ def monitor(
|
|
|
273
273
|
sync_target: Annotated[
|
|
274
274
|
str, typer.Option("--sync-target", hidden=True, help="Linked tmux session for pane sync (set by `overcode split`)")
|
|
275
275
|
] = "",
|
|
276
|
+
jobs: Annotated[
|
|
277
|
+
bool, typer.Option("--jobs", help="Start in jobs view")
|
|
278
|
+
] = False,
|
|
276
279
|
):
|
|
277
280
|
"""Launch the standalone TUI monitor."""
|
|
278
281
|
if restart:
|
|
@@ -299,7 +302,7 @@ def monitor(
|
|
|
299
302
|
|
|
300
303
|
from ..tui import run_tui
|
|
301
304
|
|
|
302
|
-
run_tui(session, diagnostics=diagnostics, sync_target=sync_target or None)
|
|
305
|
+
run_tui(session, diagnostics=diagnostics, sync_target=sync_target or None, initial_jobs_mode=jobs)
|
|
303
306
|
|
|
304
307
|
|
|
305
308
|
@app.command()
|
|
@@ -191,7 +191,7 @@ def _remove_keybindings() -> None:
|
|
|
191
191
|
from ..config import get_tmux_toggle_key
|
|
192
192
|
|
|
193
193
|
toggle_key = get_tmux_toggle_key() or DEFAULT_TOGGLE_KEY
|
|
194
|
-
keys = [toggle_key, "M-j", "M-k", "PPage", "NPage", "WheelUpPane", "WheelDownPane"]
|
|
194
|
+
keys = [toggle_key, "M-j", "M-k", "M-b", "PPage", "NPage", "WheelUpPane", "WheelDownPane"]
|
|
195
195
|
# Also unbind Tab if toggle_key changed away from it (stale binding)
|
|
196
196
|
if toggle_key != "Tab":
|
|
197
197
|
keys.append("Tab")
|
|
@@ -248,6 +248,13 @@ def _setup_keybindings(linked_session: str = "", toggle_key: str = "") -> None:
|
|
|
248
248
|
f"send-keys -t overcode:{SPLIT_WINDOW_NAME}.0 k",
|
|
249
249
|
"send-keys M-k",
|
|
250
250
|
)
|
|
251
|
+
# Option+B: navigate to bell (next agent needing attention)
|
|
252
|
+
_tmux(
|
|
253
|
+
"bind-key", "-n", "M-b",
|
|
254
|
+
"if-shell", "-F", _in_bottom,
|
|
255
|
+
f"send-keys -t overcode:{SPLIT_WINDOW_NAME}.0 b",
|
|
256
|
+
"send-keys M-b",
|
|
257
|
+
)
|
|
251
258
|
|
|
252
259
|
# --- Scrollback for the nested tmux in the bottom pane ---
|
|
253
260
|
# The bottom pane runs a nested tmux client. The outer tmux
|
|
@@ -342,6 +342,19 @@ def save_new_agent_defaults(defaults: dict) -> None:
|
|
|
342
342
|
save_config(config)
|
|
343
343
|
|
|
344
344
|
|
|
345
|
+
def get_jobs_retention_hours() -> float:
|
|
346
|
+
"""Get job retention period in hours.
|
|
347
|
+
|
|
348
|
+
Config format in ~/.overcode/config.yaml:
|
|
349
|
+
jobs:
|
|
350
|
+
retention_hours: 24
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Retention hours (default 24)
|
|
354
|
+
"""
|
|
355
|
+
return float(_get_config_value("jobs.retention_hours", 24))
|
|
356
|
+
|
|
357
|
+
|
|
345
358
|
def get_sisters_config() -> List[dict]:
|
|
346
359
|
"""Get sister instance configuration for cross-machine monitoring.
|
|
347
360
|
|
|
@@ -99,6 +99,28 @@ class ClaudeSessionStats:
|
|
|
99
99
|
return sorted_times[n // 2]
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
def synthesize_remote_stats(session) -> "ClaudeSessionStats":
|
|
103
|
+
"""Synthesize ClaudeSessionStats for a remote session from daemon_state.
|
|
104
|
+
|
|
105
|
+
Remote sessions carry a remote_daemon_state dict with all
|
|
106
|
+
SessionDaemonState fields. Extract what we need so that render
|
|
107
|
+
columns (cost, tokens, context %, model) display correctly.
|
|
108
|
+
"""
|
|
109
|
+
rds = getattr(session, 'remote_daemon_state', None) or {}
|
|
110
|
+
stats = session.stats
|
|
111
|
+
mwt = getattr(session, 'remote_median_work_time', None) or rds.get('median_work_time', 0.0)
|
|
112
|
+
return ClaudeSessionStats(
|
|
113
|
+
interaction_count=stats.interaction_count,
|
|
114
|
+
input_tokens=rds.get('input_tokens', stats.total_tokens),
|
|
115
|
+
output_tokens=rds.get('output_tokens', 0),
|
|
116
|
+
cache_creation_tokens=rds.get('cache_creation_tokens', 0),
|
|
117
|
+
cache_read_tokens=rds.get('cache_read_tokens', 0),
|
|
118
|
+
work_times=[mwt] if mwt > 0 else [],
|
|
119
|
+
current_context_tokens=rds.get('current_context_tokens', 0),
|
|
120
|
+
model=rds.get('model'),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
102
124
|
@dataclass
|
|
103
125
|
class HistoryEntry:
|
|
104
126
|
"""A single interaction from Claude Code history."""
|
|
@@ -235,6 +235,16 @@ class RealTmux:
|
|
|
235
235
|
from .tmux_utils import attach_bare
|
|
236
236
|
attach_bare(session, window)
|
|
237
237
|
|
|
238
|
+
def get_pane_pid(self, session: str, window: str) -> Optional[int]:
|
|
239
|
+
"""Get the PID of the shell process in a window's first pane."""
|
|
240
|
+
try:
|
|
241
|
+
pane = self._get_pane(session, window)
|
|
242
|
+
if pane is None:
|
|
243
|
+
return None
|
|
244
|
+
return int(pane.pane_pid)
|
|
245
|
+
except (LibTmuxException, ValueError, TypeError):
|
|
246
|
+
return None
|
|
247
|
+
|
|
238
248
|
def select_window(self, session: str, window: str) -> bool:
|
|
239
249
|
"""Select a window in a tmux session (for external pane sync)."""
|
|
240
250
|
try:
|
|
@@ -246,6 +256,33 @@ class RealTmux:
|
|
|
246
256
|
except LibTmuxException:
|
|
247
257
|
return False
|
|
248
258
|
|
|
259
|
+
def resize_window(self, session: str, window: str, width: int, height: int) -> bool:
|
|
260
|
+
"""Resize a tmux window to match terminal dimensions.
|
|
261
|
+
|
|
262
|
+
Used to fix window size mismatches when switching between sessions
|
|
263
|
+
after terminal resize (#245).
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
session: Session name
|
|
267
|
+
window: Window name/index
|
|
268
|
+
width: Target width in columns
|
|
269
|
+
height: Target height in rows
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
True if resize succeeded, False otherwise
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
# Use tmux resize-window command
|
|
276
|
+
# Format: session:window.pane
|
|
277
|
+
target = f"{session}:{window}"
|
|
278
|
+
result = self.run(
|
|
279
|
+
["tmux", "resize-window", "-t", target, "-x", str(width), "-y", str(height)],
|
|
280
|
+
timeout=2
|
|
281
|
+
)
|
|
282
|
+
return result is not None and result.get("returncode") == 0
|
|
283
|
+
except Exception:
|
|
284
|
+
return False
|
|
285
|
+
|
|
249
286
|
|
|
250
287
|
class RealFileSystem:
|
|
251
288
|
"""Production implementation of FileSystemInterface"""
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Job launcher for Overcode.
|
|
3
|
+
|
|
4
|
+
Launches bash commands as tracked jobs in a dedicated "jobs" tmux session.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import uuid as _uuid
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from .job_manager import Job, JobManager, _slugify_command
|
|
16
|
+
from .tmux_manager import TmuxManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JobLauncher:
|
|
20
|
+
"""Launches and manages bash jobs in a dedicated tmux session."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
tmux_session: str = "jobs",
|
|
25
|
+
tmux_manager: Optional[TmuxManager] = None,
|
|
26
|
+
job_manager: Optional[JobManager] = None,
|
|
27
|
+
):
|
|
28
|
+
self.tmux_session = tmux_session
|
|
29
|
+
self.tmux = tmux_manager or TmuxManager(session_name=tmux_session)
|
|
30
|
+
self.jobs = job_manager or JobManager()
|
|
31
|
+
|
|
32
|
+
def launch(
|
|
33
|
+
self,
|
|
34
|
+
command: str,
|
|
35
|
+
name: Optional[str] = None,
|
|
36
|
+
directory: Optional[str] = None,
|
|
37
|
+
agent_session_id: Optional[str] = None,
|
|
38
|
+
agent_name: Optional[str] = None,
|
|
39
|
+
) -> Job:
|
|
40
|
+
"""Launch a bash command as a tracked job.
|
|
41
|
+
|
|
42
|
+
Creates a tmux window, sends a wrapper script that reports completion,
|
|
43
|
+
and persists the job to state.
|
|
44
|
+
"""
|
|
45
|
+
directory = directory or os.getcwd()
|
|
46
|
+
base_name = name or _slugify_command(command)
|
|
47
|
+
auto_name = f"{base_name}-{_uuid.uuid4().hex[:4]}"
|
|
48
|
+
|
|
49
|
+
# Create job first to get the ID
|
|
50
|
+
job = self.jobs.create_job(
|
|
51
|
+
command=command,
|
|
52
|
+
name=auto_name,
|
|
53
|
+
start_directory=directory,
|
|
54
|
+
agent_session_id=agent_session_id,
|
|
55
|
+
agent_name=agent_name,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Create tmux window
|
|
59
|
+
window_name = job.name
|
|
60
|
+
self.tmux.ensure_session()
|
|
61
|
+
result = self.tmux.create_window(window_name, start_directory=directory)
|
|
62
|
+
if result is None:
|
|
63
|
+
# Cleanup on failure
|
|
64
|
+
self.jobs.delete_job(job.id)
|
|
65
|
+
raise RuntimeError(f"Failed to create tmux window for job '{job.name}'")
|
|
66
|
+
|
|
67
|
+
# Update job with actual window name
|
|
68
|
+
self.jobs.update_job(job.id, tmux_window=result)
|
|
69
|
+
job.tmux_window = result
|
|
70
|
+
|
|
71
|
+
# Build wrapper script that runs the command and reports completion.
|
|
72
|
+
# The exit code is written to a file as a fallback — if the _complete
|
|
73
|
+
# call doesn't run, the process monitor can still read it.
|
|
74
|
+
escaped_cmd = command.replace("'", "'\\''")
|
|
75
|
+
escaped_dir = shlex.quote(directory)
|
|
76
|
+
exit_file = self._exit_code_path(job.id)
|
|
77
|
+
wrapper = (
|
|
78
|
+
f"echo '╭─── Job: {job.name}' && "
|
|
79
|
+
f"echo '│ Command: {command}' && "
|
|
80
|
+
f"echo '│ Directory: {directory}' && "
|
|
81
|
+
f"echo '│ Started: {job.start_time}' && "
|
|
82
|
+
f"echo '╰───────────────────────────────────' && "
|
|
83
|
+
f"echo '' && "
|
|
84
|
+
f"cd {escaped_dir} && "
|
|
85
|
+
f"eval '{escaped_cmd}'; "
|
|
86
|
+
f"__oc_exit=$?; "
|
|
87
|
+
f"echo $__oc_exit > {exit_file}; "
|
|
88
|
+
f"echo ''; "
|
|
89
|
+
f"echo '╭─── Job finished ───'; "
|
|
90
|
+
f"echo \"│ Exit code: $__oc_exit\"; "
|
|
91
|
+
f"echo '╰───────────────────'; "
|
|
92
|
+
f"overcode jobs _complete {job.id} $__oc_exit; "
|
|
93
|
+
f"exec bash"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Send the wrapper to tmux
|
|
97
|
+
self.tmux.send_keys(result, wrapper)
|
|
98
|
+
|
|
99
|
+
return job
|
|
100
|
+
|
|
101
|
+
def list_jobs(self, include_completed: bool = False, detect_killed: bool = True) -> List[Job]:
|
|
102
|
+
"""List jobs, cross-referencing tmux to detect killed/completed jobs.
|
|
103
|
+
|
|
104
|
+
Detection layers for running jobs:
|
|
105
|
+
1. Window gone + no _complete call → killed
|
|
106
|
+
2. Window alive but shell has no child processes → command finished,
|
|
107
|
+
read exit code from file and mark completed/failed
|
|
108
|
+
"""
|
|
109
|
+
jobs = self.jobs.list_jobs(include_completed=include_completed)
|
|
110
|
+
|
|
111
|
+
if not detect_killed:
|
|
112
|
+
return jobs
|
|
113
|
+
|
|
114
|
+
# Get list of actual tmux windows
|
|
115
|
+
windows = self.tmux.list_windows()
|
|
116
|
+
window_names = {w['name'] for w in windows}
|
|
117
|
+
|
|
118
|
+
for job in jobs:
|
|
119
|
+
if job.status != "running":
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if job.tmux_window not in window_names:
|
|
123
|
+
# Window gone but no _complete called → killed
|
|
124
|
+
self.jobs.update_job(
|
|
125
|
+
job.id,
|
|
126
|
+
status="killed",
|
|
127
|
+
end_time=datetime.now().isoformat(),
|
|
128
|
+
)
|
|
129
|
+
job.status = "killed"
|
|
130
|
+
job.end_time = datetime.now().isoformat()
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Window still alive — check if the command has finished by
|
|
134
|
+
# looking for child processes of the pane's shell.
|
|
135
|
+
pane_pid = self.tmux.get_pane_pid(job.tmux_window)
|
|
136
|
+
if pane_pid is not None and not _has_child_processes(pane_pid):
|
|
137
|
+
exit_code = self._read_exit_code(job.id)
|
|
138
|
+
status = "completed" if exit_code == 0 else "failed"
|
|
139
|
+
now = datetime.now().isoformat()
|
|
140
|
+
self.jobs.mark_complete(job.id, exit_code)
|
|
141
|
+
job.status = status
|
|
142
|
+
job.exit_code = exit_code
|
|
143
|
+
job.end_time = now
|
|
144
|
+
self._cleanup_exit_code_file(job.id)
|
|
145
|
+
|
|
146
|
+
return jobs
|
|
147
|
+
|
|
148
|
+
def kill_job(self, name: str) -> bool:
|
|
149
|
+
"""Kill a job by name."""
|
|
150
|
+
job = self.jobs.get_job_by_name(name)
|
|
151
|
+
if not job:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
if job.status != "running":
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# Kill the tmux window
|
|
158
|
+
killed = self.tmux.kill_window(job.tmux_window)
|
|
159
|
+
|
|
160
|
+
# Update state
|
|
161
|
+
self.jobs.update_job(
|
|
162
|
+
job.id,
|
|
163
|
+
status="killed",
|
|
164
|
+
end_time=datetime.now().isoformat(),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return killed
|
|
168
|
+
|
|
169
|
+
def attach(self, name: str, bare: bool = False):
|
|
170
|
+
"""Attach terminal to a job's tmux window."""
|
|
171
|
+
job = self.jobs.get_job_by_name(name)
|
|
172
|
+
if not job:
|
|
173
|
+
raise ValueError(f"Job '{name}' not found")
|
|
174
|
+
|
|
175
|
+
self.tmux.attach_session(window=job.tmux_window, bare=bare)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _exit_code_path(job_id: str) -> Path:
|
|
179
|
+
"""Path to the exit code file for a job."""
|
|
180
|
+
env_state_dir = os.environ.get("OVERCODE_STATE_DIR")
|
|
181
|
+
base = Path(env_state_dir) / "jobs" if env_state_dir else Path.home() / ".overcode" / "jobs"
|
|
182
|
+
return base / f"exit-{job_id}"
|
|
183
|
+
|
|
184
|
+
def _read_exit_code(self, job_id: str) -> int:
|
|
185
|
+
"""Read exit code from file, defaulting to 0 if missing/unreadable."""
|
|
186
|
+
path = self._exit_code_path(job_id)
|
|
187
|
+
try:
|
|
188
|
+
return int(path.read_text().strip())
|
|
189
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
def _cleanup_exit_code_file(self, job_id: str):
|
|
193
|
+
"""Remove the exit code file after reading it."""
|
|
194
|
+
try:
|
|
195
|
+
self._exit_code_path(job_id).unlink(missing_ok=True)
|
|
196
|
+
except OSError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _has_child_processes(pid: int) -> bool:
|
|
201
|
+
"""Check if a process has any child processes."""
|
|
202
|
+
try:
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
["pgrep", "-P", str(pid)],
|
|
205
|
+
capture_output=True,
|
|
206
|
+
timeout=5,
|
|
207
|
+
)
|
|
208
|
+
return result.returncode == 0
|
|
209
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
210
|
+
# If we can't check, assume still running
|
|
211
|
+
return True
|