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.
Files changed (119) hide show
  1. {overcode-0.3.0/src/overcode.egg-info → overcode-0.3.1}/PKG-INFO +1 -1
  2. {overcode-0.3.0 → overcode-0.3.1}/pyproject.toml +1 -1
  3. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/bundled_skills.py +44 -0
  4. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/__init__.py +1 -0
  5. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/_shared.py +8 -0
  6. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/agent.py +8 -0
  7. overcode-0.3.1/src/overcode/cli/jobs.py +156 -0
  8. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/monitoring.py +4 -1
  9. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/split.py +8 -1
  10. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/config.py +13 -0
  11. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/history_reader.py +22 -0
  12. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/implementations.py +37 -0
  13. overcode-0.3.1/src/overcode/job_launcher.py +211 -0
  14. overcode-0.3.1/src/overcode/job_manager.py +351 -0
  15. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/launcher.py +8 -1
  16. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/mocks.py +4 -0
  17. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/monitor_daemon.py +13 -7
  18. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/monitor_daemon_core.py +8 -8
  19. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/monitor_daemon_state.py +4 -0
  20. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/protocols.py +8 -0
  21. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/session_manager.py +5 -0
  22. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/settings.py +69 -12
  23. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/sister_poller.py +2 -0
  24. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_detector.py +7 -2
  25. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_patterns.py +83 -0
  26. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/summary_columns.py +27 -6
  27. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tmux_manager.py +16 -0
  28. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui.py +384 -109
  29. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui.tcss +43 -16
  30. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/input.py +4 -1
  31. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/navigation.py +14 -2
  32. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/session.py +10 -9
  33. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/view.py +57 -46
  34. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_render.py +6 -9
  35. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/__init__.py +2 -0
  36. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/help_overlay.py +4 -6
  37. overcode-0.3.1/src/overcode/tui_widgets/job_summary.py +129 -0
  38. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/preview_pane.py +20 -0
  39. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/session_summary.py +14 -112
  40. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_api.py +1 -0
  41. {overcode-0.3.0 → overcode-0.3.1/src/overcode.egg-info}/PKG-INFO +1 -1
  42. {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/SOURCES.txt +4 -0
  43. {overcode-0.3.0 → overcode-0.3.1}/LICENSE +0 -0
  44. {overcode-0.3.0 → overcode-0.3.1}/MANIFEST.in +0 -0
  45. {overcode-0.3.0 → overcode-0.3.1}/README.md +0 -0
  46. {overcode-0.3.0 → overcode-0.3.1}/setup.cfg +0 -0
  47. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/__init__.py +0 -0
  48. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/agent_scanner.py +0 -0
  49. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/claude_config.py +0 -0
  50. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/claude_pid.py +0 -0
  51. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/__main__.py +0 -0
  52. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/budget.py +0 -0
  53. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/config.py +0 -0
  54. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/daemon.py +0 -0
  55. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/hooks.py +0 -0
  56. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/perms.py +0 -0
  57. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/sister.py +0 -0
  58. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/cli/skills.py +0 -0
  59. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/daemon_claude_skill.md +0 -0
  60. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/daemon_logging.py +0 -0
  61. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/daemon_utils.py +0 -0
  62. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/data_export.py +0 -0
  63. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/dependency_check.py +0 -0
  64. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/duration.py +0 -0
  65. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/exceptions.py +0 -0
  66. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/follow_mode.py +0 -0
  67. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/hook_handler.py +0 -0
  68. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/hook_status_detector.py +0 -0
  69. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/interfaces.py +0 -0
  70. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/logging_config.py +0 -0
  71. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/notifier.py +0 -0
  72. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/pid_utils.py +0 -0
  73. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/presence_logger.py +0 -0
  74. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/sister_controller.py +0 -0
  75. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/standing_instructions.py +0 -0
  76. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_constants.py +0 -0
  77. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_detector_factory.py +0 -0
  78. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/status_history.py +0 -0
  79. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/summarizer_client.py +0 -0
  80. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/summarizer_component.py +0 -0
  81. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/summary_groups.py +0 -0
  82. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/supervisor_daemon.py +0 -0
  83. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/supervisor_daemon_core.py +0 -0
  84. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/supervisor_layout.sh +0 -0
  85. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/__init__.py +0 -0
  86. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/renderer.py +0 -0
  87. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/tmux_driver.py +0 -0
  88. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/tui_eye.py +0 -0
  89. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/testing/tui_eye_skill.md +0 -0
  90. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/time_context.py +0 -0
  91. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tmux_utils.py +0 -0
  92. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/__init__.py +0 -0
  93. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_actions/daemon.py +0 -0
  94. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_helpers.py +0 -0
  95. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_logic.py +0 -0
  96. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/agent_select_modal.py +0 -0
  97. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/command_bar.py +0 -0
  98. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/daemon_panel.py +0 -0
  99. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
  100. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  101. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/instruction_history_modal.py +0 -0
  102. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/new_agent_defaults_modal.py +0 -0
  103. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/sister_selection_modal.py +0 -0
  104. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/status_timeline.py +0 -0
  105. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  106. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/usage_monitor.py +0 -0
  107. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web/__init__.py +0 -0
  108. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web/templates/analytics.html +0 -0
  109. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web/templates/dashboard.html +0 -0
  110. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_chartjs.py +0 -0
  111. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_control_api.py +0 -0
  112. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_server.py +0 -0
  113. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_server_runner.py +0 -0
  114. {overcode-0.3.0 → overcode-0.3.1}/src/overcode/web_templates.py +0 -0
  115. {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/dependency_links.txt +0 -0
  116. {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/entry_points.txt +0 -0
  117. {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/requires.txt +0 -0
  118. {overcode-0.3.0 → overcode-0.3.1}/src/overcode.egg-info/top_level.txt +0 -0
  119. {overcode-0.3.0 → overcode-0.3.1}/tests/test_e2e_multi_agent_jokes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overcode
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A supervisor for managing multiple Claude Code instances in tmux
5
5
  Author: Mike Bond
6
6
  Project-URL: Homepage, https://github.com/mkb23/overcode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "overcode"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -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
@@ -22,6 +22,7 @@ from . import daemon # noqa: F401
22
22
  from . import sister # noqa: F401
23
23
  from . import config # noqa: F401
24
24
  from . import split # noqa: F401
25
+ from . import jobs # noqa: F401
25
26
 
26
27
 
27
28
  def main():
@@ -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