overcode 0.2.1__tar.gz → 0.2.2__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 (108) hide show
  1. {overcode-0.2.1/src/overcode.egg-info → overcode-0.2.2}/PKG-INFO +1 -1
  2. {overcode-0.2.1 → overcode-0.2.2}/pyproject.toml +1 -1
  3. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/agent.py +137 -32
  4. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/config.py +42 -0
  5. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/history_reader.py +69 -74
  6. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/launcher.py +37 -1
  7. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/monitor_daemon.py +10 -3
  8. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/monitor_daemon_state.py +10 -168
  9. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/session_manager.py +5 -1
  10. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/settings.py +3 -3
  11. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/status_patterns.py +55 -1
  12. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/summary_columns.py +171 -28
  13. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/summary_groups.py +1 -1
  14. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui.py +213 -71
  15. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui.tcss +16 -0
  16. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_actions/input.py +3 -3
  17. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_actions/session.py +28 -3
  18. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_actions/view.py +20 -4
  19. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_logic.py +5 -4
  20. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/__init__.py +2 -0
  21. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/command_bar.py +181 -24
  22. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/daemon_status_bar.py +39 -13
  23. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/help_overlay.py +4 -0
  24. overcode-0.2.2/src/overcode/tui_widgets/new_agent_defaults_modal.py +131 -0
  25. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/session_summary.py +26 -17
  26. {overcode-0.2.1 → overcode-0.2.2/src/overcode.egg-info}/PKG-INFO +1 -1
  27. {overcode-0.2.1 → overcode-0.2.2}/src/overcode.egg-info/SOURCES.txt +1 -0
  28. {overcode-0.2.1 → overcode-0.2.2}/LICENSE +0 -0
  29. {overcode-0.2.1 → overcode-0.2.2}/MANIFEST.in +0 -0
  30. {overcode-0.2.1 → overcode-0.2.2}/README.md +0 -0
  31. {overcode-0.2.1 → overcode-0.2.2}/setup.cfg +0 -0
  32. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/__init__.py +0 -0
  33. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/bundled_skills.py +0 -0
  34. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/claude_config.py +0 -0
  35. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/__init__.py +0 -0
  36. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/__main__.py +0 -0
  37. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/_shared.py +0 -0
  38. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/budget.py +0 -0
  39. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/config.py +0 -0
  40. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/daemon.py +0 -0
  41. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/hooks.py +0 -0
  42. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/monitoring.py +0 -0
  43. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/perms.py +0 -0
  44. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/sister.py +0 -0
  45. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/cli/skills.py +0 -0
  46. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/daemon_claude_skill.md +0 -0
  47. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/daemon_logging.py +0 -0
  48. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/daemon_utils.py +0 -0
  49. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/data_export.py +0 -0
  50. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/dependency_check.py +0 -0
  51. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/exceptions.py +0 -0
  52. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/follow_mode.py +0 -0
  53. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/hook_handler.py +0 -0
  54. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/hook_status_detector.py +0 -0
  55. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/implementations.py +0 -0
  56. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/interfaces.py +0 -0
  57. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/logging_config.py +0 -0
  58. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/mocks.py +0 -0
  59. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/monitor_daemon_core.py +0 -0
  60. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/notifier.py +0 -0
  61. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/pid_utils.py +0 -0
  62. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/presence_logger.py +0 -0
  63. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/protocols.py +0 -0
  64. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/sister_controller.py +0 -0
  65. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/sister_poller.py +0 -0
  66. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/standing_instructions.py +0 -0
  67. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/status_constants.py +0 -0
  68. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/status_detector.py +0 -0
  69. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/status_detector_factory.py +0 -0
  70. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/status_history.py +0 -0
  71. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/summarizer_client.py +0 -0
  72. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/summarizer_component.py +0 -0
  73. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/supervisor_daemon.py +0 -0
  74. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/supervisor_daemon_core.py +0 -0
  75. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/supervisor_layout.sh +0 -0
  76. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/testing/__init__.py +0 -0
  77. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/testing/renderer.py +0 -0
  78. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/testing/tmux_driver.py +0 -0
  79. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/testing/tui_eye.py +0 -0
  80. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/testing/tui_eye_skill.md +0 -0
  81. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/time_context.py +0 -0
  82. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tmux_manager.py +0 -0
  83. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tmux_utils.py +0 -0
  84. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_actions/__init__.py +0 -0
  85. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_actions/daemon.py +0 -0
  86. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_actions/navigation.py +0 -0
  87. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_helpers.py +0 -0
  88. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_render.py +0 -0
  89. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/daemon_panel.py +0 -0
  90. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  91. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/preview_pane.py +0 -0
  92. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/status_timeline.py +0 -0
  93. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  94. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/usage_monitor.py +0 -0
  95. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web/__init__.py +0 -0
  96. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web/templates/analytics.html +0 -0
  97. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web/templates/dashboard.html +0 -0
  98. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web_api.py +0 -0
  99. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web_chartjs.py +0 -0
  100. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web_control_api.py +0 -0
  101. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web_server.py +0 -0
  102. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web_server_runner.py +0 -0
  103. {overcode-0.2.1 → overcode-0.2.2}/src/overcode/web_templates.py +0 -0
  104. {overcode-0.2.1 → overcode-0.2.2}/src/overcode.egg-info/dependency_links.txt +0 -0
  105. {overcode-0.2.1 → overcode-0.2.2}/src/overcode.egg-info/entry_points.txt +0 -0
  106. {overcode-0.2.1 → overcode-0.2.2}/src/overcode.egg-info/requires.txt +0 -0
  107. {overcode-0.2.1 → overcode-0.2.2}/src/overcode.egg-info/top_level.txt +0 -0
  108. {overcode-0.2.1 → overcode-0.2.2}/tests/test_e2e_multi_agent_jokes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overcode
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A supervisor for managing multiple Claude Code instances in tmux
5
5
  Author: Mike Bond
6
6
  Project-URL: Homepage, https://github.com/mkb23/overcode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "overcode"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -59,11 +59,65 @@ def launch(
59
59
  Optional[List[str]],
60
60
  typer.Option("--claude-arg", help="Extra Claude CLI flag (repeatable, e.g. '--model haiku')"),
61
61
  ] = None,
62
+ teams: Annotated[
63
+ bool,
64
+ typer.Option("--teams", help="Enable Claude Code agent teams (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS)"),
65
+ ] = False,
66
+ sister: Annotated[
67
+ Optional[str],
68
+ typer.Option("--sister", "-S", help="Launch on a remote sister machine (by name from config)"),
69
+ ] = None,
62
70
  session: SessionOption = "agents",
63
71
  ):
64
72
  """Launch a new Claude agent."""
65
73
  import os
66
74
 
75
+ # Remote launch via sister
76
+ if sister:
77
+ from ..config import get_sister_by_name
78
+ from ..sister_controller import SisterController
79
+
80
+ sister_config = get_sister_by_name(sister)
81
+ if not sister_config:
82
+ from ..config import get_sisters_config
83
+ available = [s["name"] for s in get_sisters_config()]
84
+ if available:
85
+ rprint(f"[red]Error: Sister '{sister}' not found. Available: {', '.join(available)}[/red]")
86
+ else:
87
+ rprint(f"[red]Error: No sisters configured. Add sisters to ~/.overcode/config.yaml[/red]")
88
+ raise typer.Exit(code=1)
89
+
90
+ remote_dir = directory if directory else "."
91
+ if not directory:
92
+ rprint("[yellow]Warning:[/yellow] No --directory given; remote agent will use '.'")
93
+
94
+ # Map permission flags to permissions string
95
+ if bypass_permissions:
96
+ permissions = "bypass"
97
+ elif skip_permissions:
98
+ permissions = "skip"
99
+ else:
100
+ permissions = "normal"
101
+
102
+ controller = SisterController()
103
+ result = controller.launch_agent(
104
+ sister_url=sister_config["url"],
105
+ api_key=sister_config.get("api_key", ""),
106
+ directory=remote_dir,
107
+ name=name,
108
+ prompt=prompt,
109
+ permissions=permissions,
110
+ )
111
+
112
+ if result.ok:
113
+ rprint(f"\n[green]✓[/green] Remote agent '[bold]{name}[/bold]' launched on [bold]{sister}[/bold]")
114
+ if prompt:
115
+ rprint(" Initial prompt sent")
116
+ else:
117
+ rprint(f"[red]Error: Remote launch failed: {result.error}[/red]")
118
+ raise typer.Exit(code=1)
119
+ return
120
+
67
121
  # Parse oversight policy
68
122
  oversight_policy = "wait"
69
123
  oversight_timeout_seconds = 0.0
@@ -104,6 +158,7 @@ def launch(
104
158
  parent_name=parent,
105
159
  allowed_tools=allowed_tools,
106
160
  extra_claude_args=claude_args,
161
+ agent_teams=teams,
107
162
  )
108
163
 
109
164
  if result:
@@ -116,6 +171,8 @@ def launch(
116
171
  rprint(f" Allowed tools: {allowed_tools}")
117
172
  if claude_args:
118
173
  rprint(f" Extra Claude args: {' '.join(claude_args)}")
174
+ if teams:
175
+ rprint(" Agent teams: enabled")
119
176
 
120
177
  # Store oversight policy on session
121
178
  if oversight_policy != "wait" or oversight_timeout_seconds > 0:
@@ -155,6 +212,15 @@ def list_agents(
155
212
  sisters: Annotated[
156
213
  bool, typer.Option("--sisters", help="Include sister (remote) agents")
157
214
  ] = False,
215
+ low: Annotated[
216
+ bool, typer.Option("--low", help="Low detail (identity only)")
217
+ ] = False,
218
+ med: Annotated[
219
+ bool, typer.Option("--med", help="Medium detail")
220
+ ] = False,
221
+ full: Annotated[
222
+ bool, typer.Option("--full", help="Full detail (default)")
223
+ ] = False,
158
224
  session: SessionOption = "agents",
159
225
  ):
160
226
  """List running agents with status.
@@ -170,19 +236,30 @@ def list_agents(
170
236
  get_status_symbol, get_git_diff_stats,
171
237
  )
172
238
  from ..monitor_daemon_state import get_monitor_daemon_state
173
- from ..summary_columns import build_cli_context, SUMMARY_COLUMNS
239
+ from ..summary_columns import build_cli_context, render_summary_cells, align_summary_rows
174
240
  from ..tui_logic import compute_tree_metadata, sort_sessions_by_tree
175
- from rich.text import Text
176
241
  from rich.console import Console
177
242
 
243
+ # Resolve detail level (mutually exclusive flags, default full)
244
+ if low:
245
+ detail = "low"
246
+ elif med:
247
+ detail = "med"
248
+ else:
249
+ detail = "full"
250
+
178
251
  launcher = ClaudeLauncher(session)
179
252
  sessions = launcher.list_sessions()
180
253
 
181
254
  # Merge sister sessions if --sisters flag
255
+ has_sisters = False
256
+ local_hostname = ""
182
257
  if sisters:
183
258
  from ..sister_poller import SisterPoller
184
259
  poller = SisterPoller()
185
- if poller.has_sisters:
260
+ has_sisters = poller.has_sisters
261
+ local_hostname = poller.local_hostname
262
+ if has_sisters:
186
263
  remote_sessions = poller.poll_all()
187
264
  sessions = sessions + remote_sessions
188
265
 
@@ -212,23 +289,38 @@ def list_agents(
212
289
  sessions = sort_sessions_by_tree(sessions)
213
290
  tree_meta = compute_tree_metadata(sessions)
214
291
 
215
- # Columns to render in list mode (subset of TUI columns)
216
- list_columns = {
217
- "status_symbol", "time_in_state", "sleep_countdown", "agent_name",
218
- "git_diff",
219
- "uptime", "running_time", "stalled_time", "sleep_time",
220
- "token_count", "cost", "budget", "context_usage",
221
- }
222
-
223
- # Pre-compute: any agent with budget, max name width
292
+ # Pre-compute: any agent with budget, column alignment widths
224
293
  any_has_budget = any(s.cost_budget_usd > 0 for s in sessions)
225
294
  max_name_len = max(len(s.name) for s in sessions)
226
295
  name_width = min(max(max_name_len, 10), 20)
296
+ max_repo_width = max((len(s.repo_name or "n/a") for s in sessions), default=5)
297
+ max_branch_width = max((len(s.branch or "n/a") for s in sessions), default=5)
298
+ all_names_match_repos = all(
299
+ s.name == s.repo_name for s in sessions if s.repo_name
300
+ )
227
301
 
228
302
  # Prefer daemon state for status/activity (single source of truth)
229
303
  daemon_state = get_monitor_daemon_state(session)
230
304
  use_daemon = daemon_state is not None and not daemon_state.is_stale()
231
305
 
306
+ # Compute cross-session flags from daemon state
307
+ any_has_oversight_timeout = False
308
+ any_has_pr = False
309
+ if use_daemon:
310
+ any_has_oversight_timeout = any(
311
+ ds.oversight_timeout_seconds > 0
312
+ for ds in daemon_state.sessions
313
+ )
314
+ else:
315
+ any_has_oversight_timeout = any(
316
+ getattr(s, 'oversight_timeout_seconds', 0) > 0
317
+ for s in sessions
318
+ )
319
+ any_has_pr = any(
320
+ getattr(s, 'pr_number', None) is not None
321
+ for s in sessions
322
+ )
323
+
232
324
  # Only create detector as fallback when daemon isn't running
233
325
  detector = None
234
326
  if not use_daemon:
@@ -288,24 +380,43 @@ def list_agents(
288
380
  # Compute cross-session flags
289
381
  any_is_sleeping = any(st == "busy_sleeping" for _, st, _, _, _ in session_data)
290
382
 
291
- # Second pass: render
383
+ # Second pass: build contexts and collect cells for auto-alignment
384
+ all_cells = []
385
+ activities = []
292
386
  for sess, status, activity, claude_stats, git_diff in session_data:
293
387
  meta = tree_meta.get(sess.id)
294
388
  child_count = meta.child_count if meta else 0
389
+
390
+ # Get per-session daemon fields
391
+ oversight_deadline = None
392
+ if use_daemon:
393
+ ds = daemon_state.get_session_by_name(sess.name)
394
+ if ds:
395
+ oversight_deadline = ds.oversight_deadline
396
+
397
+ _, status_color = get_status_symbol(status)
295
398
  ctx = build_cli_context(
296
399
  session=sess, stats=sess.stats,
297
400
  claude_stats=claude_stats, git_diff_stats=git_diff,
298
401
  status=status, bg_bash_count=0, live_sub_count=0,
299
402
  any_has_budget=any_has_budget, child_count=child_count,
300
403
  any_is_sleeping=any_is_sleeping,
404
+ any_has_oversight_timeout=any_has_oversight_timeout,
405
+ oversight_deadline=oversight_deadline,
406
+ pr_number=getattr(sess, 'pr_number', None),
407
+ any_has_pr=any_has_pr,
408
+ monochrome=False,
409
+ summary_detail=detail,
410
+ has_sisters=has_sisters,
411
+ local_hostname=local_hostname,
412
+ max_name_width=name_width,
413
+ max_repo_width=max_repo_width,
414
+ max_branch_width=max_branch_width,
415
+ all_names_match_repos=all_names_match_repos,
301
416
  )
302
-
303
- # Enable colors and set detail level for list view
304
- ctx.monochrome = False
305
- _, status_color = get_status_symbol(status)
306
417
  ctx.status_color = f"bold {status_color}"
307
- ctx.summary_detail = "med"
308
418
  ctx.show_cost = cost
419
+ ctx.is_list_mode = True
309
420
 
310
421
  # Handle tree indentation (#244) using compute_tree_metadata
311
422
  depth = meta.depth if meta else 0
@@ -313,23 +424,15 @@ def list_agents(
313
424
  available = name_width - len(indent)
314
425
  ctx.display_name = (indent + sess.name[:available]).ljust(name_width)
315
426
 
316
- # Render line using column system
317
- line = Text()
318
- for col in SUMMARY_COLUMNS:
319
- if col.id not in list_columns:
320
- continue
321
- if ctx.summary_detail not in col.detail_levels:
322
- continue
323
- segments = col.render(ctx)
324
- if segments:
325
- for text, style in segments:
326
- line.append(text, style=style)
327
-
328
- # Append activity (truncate to fit terminal width)
427
+ all_cells.append(render_summary_cells(ctx))
428
+ activities.append(activity)
429
+
430
+ # Auto-align columns across all rows, then append activity
431
+ aligned_lines = align_summary_rows(all_cells)
432
+ for line, activity in zip(aligned_lines, activities):
329
433
  line.append(" │ ", style="dim")
330
434
  line.append(activity)
331
435
  line.truncate(console.width, pad=False)
332
-
333
436
  console.print(line, no_wrap=True)
334
437
 
335
438
  if terminated_count > 0:
@@ -756,6 +859,8 @@ def show(
756
859
  print(f"{'Tools:':<{label_width + 1}} {sess.allowed_tools}")
757
860
  if sess.extra_claude_args:
758
861
  print(f"{'Claude args:':<{label_width + 1}} {' '.join(sess.extra_claude_args)}")
862
+ if sess.agent_teams:
863
+ print(f"{'Teams:':<{label_width + 1}} enabled")
759
864
 
760
865
  print()
761
866
 
@@ -264,6 +264,36 @@ def get_web_allow_control() -> bool:
264
264
  return bool(web.get("allow_control", False))
265
265
 
266
266
 
267
+ def get_new_agent_defaults() -> dict:
268
+ """Get new-agent default settings from config.
269
+
270
+ Config format in ~/.overcode/config.yaml:
271
+ new_agent_defaults:
272
+ bypass_permissions: false
273
+ agent_teams: false
274
+
275
+ Returns:
276
+ Dict with bypass_permissions (bool) and agent_teams (bool).
277
+ """
278
+ config = load_config()
279
+ defaults = config.get("new_agent_defaults", {})
280
+ return {
281
+ "bypass_permissions": bool(defaults.get("bypass_permissions", False)),
282
+ "agent_teams": bool(defaults.get("agent_teams", False)),
283
+ }
284
+
285
+
286
+ def save_new_agent_defaults(defaults: dict) -> None:
287
+ """Save new-agent default settings to config.
288
+
289
+ Args:
290
+ defaults: Dict with bypass_permissions and agent_teams booleans.
291
+ """
292
+ config = load_config()
293
+ config["new_agent_defaults"] = defaults
294
+ save_config(config)
295
+
296
+
267
297
  def get_sisters_config() -> List[dict]:
268
298
  """Get sister instance configuration for cross-machine monitoring.
269
299
 
@@ -298,3 +328,15 @@ def get_sisters_config() -> List[dict]:
298
328
  result.append(entry)
299
329
 
300
330
  return result
331
+
332
+
333
+ def get_sister_by_name(name: str) -> Optional[dict]:
334
+ """Look up a sister config by name.
335
+
336
+ Returns:
337
+ Sister config dict (name, url, optional api_key) or None if not found.
338
+ """
339
+ for sister in get_sisters_config():
340
+ if sister["name"] == name:
341
+ return sister
342
+ return None
@@ -404,31 +404,35 @@ def get_session_file_path(
404
404
  return projects_path / encoded / f"{session_id}.jsonl"
405
405
 
406
406
 
407
- def read_token_usage_from_session_file(
407
+ def read_session_file_stats(
408
408
  session_file: Path,
409
- since: Optional[datetime] = None
410
- ) -> dict:
411
- """Read token usage from a Claude Code session JSONL file.
409
+ since: Optional[datetime] = None,
410
+ ) -> Tuple[dict, List[float]]:
411
+ """Read token usage and work times from a session file in a single pass.
412
+
413
+ Combines the work of read_token_usage_from_session_file and
414
+ read_work_times_from_session_file so the file is only read once.
412
415
 
413
416
  Args:
414
417
  session_file: Path to the session JSONL file
415
- since: Only count tokens from messages after this time
418
+ since: Only count data from messages after this time
416
419
 
417
420
  Returns:
418
- Dict with input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
419
- and current_context_tokens (most recent input_tokens value)
421
+ (token_usage_dict, work_times_list)
420
422
  """
421
423
  totals = {
422
424
  "input_tokens": 0,
423
425
  "output_tokens": 0,
424
426
  "cache_creation_tokens": 0,
425
427
  "cache_read_tokens": 0,
426
- "current_context_tokens": 0, # Most recent input_tokens
427
- "model": None, # Most recently seen model name (#272)
428
+ "current_context_tokens": 0,
429
+ "model": None,
428
430
  }
429
431
 
430
432
  if not session_file.exists():
431
- return totals
433
+ return totals, []
434
+
435
+ user_prompt_times: List[datetime] = []
432
436
 
433
437
  try:
434
438
  with open(session_file, 'r') as f:
@@ -438,14 +442,14 @@ def read_token_usage_from_session_file(
438
442
  continue
439
443
  try:
440
444
  data = json.loads(line)
441
- # Only assistant messages have usage data
442
- if data.get("type") == "assistant":
445
+ msg_type = data.get("type")
446
+
447
+ if msg_type == "assistant":
443
448
  # Check timestamp if filtering by time
444
449
  if since:
445
450
  ts_str = data.get("timestamp")
446
451
  if ts_str:
447
452
  try:
448
- # Parse ISO timestamp (e.g., "2026-01-02T06:56:01.975Z")
449
453
  msg_time = datetime.fromisoformat(
450
454
  ts_str.replace("Z", "+00:00")
451
455
  ).replace(tzinfo=None)
@@ -468,15 +472,62 @@ def read_token_usage_from_session_file(
468
472
  "cache_creation_input_tokens", 0
469
473
  )
470
474
  totals["cache_read_tokens"] += cache_read
471
- # Track most recent context size (input + cached context)
472
475
  context_size = input_tokens + cache_read
473
476
  if context_size > 0:
474
477
  totals["current_context_tokens"] = context_size
478
+
479
+ elif msg_type == "user":
480
+ # Check if this is an actual user prompt (not a tool result)
481
+ message = data.get("message", {})
482
+ content = message.get("content", "")
483
+ if isinstance(content, list):
484
+ if content and content[0].get("type") == "tool_result":
485
+ continue
486
+
487
+ ts_str = data.get("timestamp")
488
+ if not ts_str:
489
+ continue
490
+
491
+ try:
492
+ msg_time = datetime.fromisoformat(
493
+ ts_str.replace("Z", "+00:00")
494
+ ).replace(tzinfo=None)
495
+ if since and msg_time < since:
496
+ continue
497
+ user_prompt_times.append(msg_time)
498
+ except (ValueError, TypeError):
499
+ continue
500
+
475
501
  except (json.JSONDecodeError, KeyError, TypeError):
476
502
  continue
477
503
  except IOError:
478
- pass
504
+ return totals, []
505
+
506
+ # Calculate durations between consecutive prompts
507
+ work_times = []
508
+ for i in range(1, len(user_prompt_times)):
509
+ duration = (user_prompt_times[i] - user_prompt_times[i - 1]).total_seconds()
510
+ if duration > 0:
511
+ work_times.append(duration)
512
+
513
+ return totals, work_times
514
+
515
+
516
+ def read_token_usage_from_session_file(
517
+ session_file: Path,
518
+ since: Optional[datetime] = None
519
+ ) -> dict:
520
+ """Read token usage from a Claude Code session JSONL file.
521
+
522
+ Args:
523
+ session_file: Path to the session JSONL file
524
+ since: Only count tokens from messages after this time
479
525
 
526
+ Returns:
527
+ Dict with input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
528
+ and current_context_tokens (most recent input_tokens value)
529
+ """
530
+ totals, _ = read_session_file_stats(session_file, since)
480
531
  return totals
481
532
 
482
533
 
@@ -498,62 +549,7 @@ def read_work_times_from_session_file(
498
549
  Returns:
499
550
  List of work times in seconds
500
551
  """
501
- if not session_file.exists():
502
- return []
503
-
504
- user_prompt_times: List[datetime] = []
505
-
506
- try:
507
- with open(session_file, 'r') as f:
508
- for line in f:
509
- line = line.strip()
510
- if not line:
511
- continue
512
- try:
513
- data = json.loads(line)
514
- if data.get("type") != "user":
515
- continue
516
-
517
- # Check if this is an actual user prompt (not a tool result)
518
- message = data.get("message", {})
519
- content = message.get("content", "")
520
-
521
- # Tool results have content as a list with tool_result type
522
- if isinstance(content, list):
523
- # Check if it's a tool result
524
- if content and content[0].get("type") == "tool_result":
525
- continue
526
-
527
- # Parse timestamp
528
- ts_str = data.get("timestamp")
529
- if not ts_str:
530
- continue
531
-
532
- try:
533
- msg_time = datetime.fromisoformat(
534
- ts_str.replace("Z", "+00:00")
535
- ).replace(tzinfo=None)
536
-
537
- # Filter by since time
538
- if since and msg_time < since:
539
- continue
540
-
541
- user_prompt_times.append(msg_time)
542
- except (ValueError, TypeError):
543
- continue
544
-
545
- except (json.JSONDecodeError, KeyError, TypeError):
546
- continue
547
- except IOError:
548
- return []
549
-
550
- # Calculate durations between consecutive prompts
551
- work_times = []
552
- for i in range(1, len(user_prompt_times)):
553
- duration = (user_prompt_times[i] - user_prompt_times[i - 1]).total_seconds()
554
- if duration > 0:
555
- work_times.append(duration)
556
-
552
+ _, work_times = read_session_file_stats(session_file, since)
557
553
  return work_times
558
554
 
559
555
 
@@ -625,7 +621,7 @@ def get_session_stats(
625
621
  session_file = get_session_file_path(
626
622
  session.start_directory, sid, projects_path
627
623
  )
628
- usage = read_token_usage_from_session_file(session_file, since=session_start)
624
+ usage, work_times = read_session_file_stats(session_file, since=session_start)
629
625
  total_input += usage["input_tokens"]
630
626
  total_output += usage["output_tokens"]
631
627
  total_cache_creation += usage["cache_creation_tokens"]
@@ -644,7 +640,6 @@ def get_session_stats(
644
640
  detected_model = usage["model"]
645
641
 
646
642
  # Collect work times from this session file
647
- work_times = read_work_times_from_session_file(session_file, since=session_start)
648
643
  all_work_times.extend(work_times)
649
644
 
650
645
  # Check for subagent files in {sessionId}/subagents/
@@ -655,7 +650,7 @@ def get_session_stats(
655
650
  subagent_count += 1
656
651
  if now - subagent_file.stat().st_mtime < 30:
657
652
  live_subagent_count += 1
658
- sub_usage = read_token_usage_from_session_file(
653
+ sub_usage, _ = read_session_file_stats(
659
654
  subagent_file, since=session_start
660
655
  )
661
656
  total_input += sub_usage["input_tokens"]
@@ -11,6 +11,7 @@ import time
11
11
  import subprocess
12
12
  import os
13
13
  import shlex
14
+ from pathlib import Path
14
15
  from typing import List, Optional
15
16
 
16
17
  import re
@@ -77,6 +78,7 @@ class ClaudeLauncher:
77
78
  parent_name: Optional[str] = None,
78
79
  allowed_tools: Optional[str] = None,
79
80
  extra_claude_args: Optional[List[str]] = None,
81
+ agent_teams: bool = False,
80
82
  ) -> Optional[Session]:
81
83
  """
82
84
  Launch an interactive Claude Code session in a tmux window.
@@ -175,6 +177,10 @@ class ClaudeLauncher:
175
177
  if parent_session:
176
178
  env_prefix += f" OVERCODE_PARENT_SESSION_ID={parent_session.id} OVERCODE_PARENT_NAME={parent_session.name}"
177
179
 
180
+ # Enable Claude Code agent teams if requested
181
+ if agent_teams:
182
+ env_prefix += " CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1"
183
+
178
184
  # If MOCK_SCENARIO is set, prepend it to the command for testing
179
185
  mock_scenario = os.environ.get("MOCK_SCENARIO")
180
186
  if mock_scenario:
@@ -198,16 +204,19 @@ class ClaudeLauncher:
198
204
 
199
205
  # Register session with default standing instructions from config
200
206
  default_instructions = get_default_standing_instructions()
207
+ # Resolve to absolute path so daemon/CLI don't disagree on CWD (#312)
208
+ resolved_directory = str(Path(start_directory).resolve()) if start_directory else None
201
209
  session = self.sessions.create_session(
202
210
  name=name,
203
211
  tmux_session=self.tmux.session_name,
204
212
  tmux_window=window_index,
205
213
  command=claude_cmd,
206
- start_directory=start_directory,
214
+ start_directory=resolved_directory,
207
215
  standing_instructions=default_instructions,
208
216
  permissiveness_mode=perm_mode,
209
217
  allowed_tools=allowed_tools,
210
218
  extra_claude_args=extra_claude_args,
219
+ agent_teams=agent_teams,
211
220
  )
212
221
 
213
222
  # Set parent if launching as child agent (#244)
@@ -467,6 +476,33 @@ class ClaudeLauncher:
467
476
  print(f"Session '{name}' not found")
468
477
  return False
469
478
 
479
+ return self._send_to_resolved_session(session, text, enter)
480
+
481
+ def send_to_session_by_id(self, session_id: str, text: str, enter: bool = True) -> bool:
482
+ """Send text/keys to a session by ID.
483
+
484
+ Preferred over send_to_session() when the session ID is known,
485
+ since IDs are unique even when local and remote agents share a name.
486
+
487
+ Args:
488
+ session_id: Unique session ID
489
+ text: Text to send (or special key like "Enter", "Escape")
490
+ enter: Whether to press Enter after the text (default: True)
491
+
492
+ Returns:
493
+ True if successful, False otherwise
494
+ """
495
+ session = self.sessions.get_session(session_id)
496
+ if session is None:
497
+ return False
498
+
499
+ return self._send_to_resolved_session(session, text, enter)
500
+
501
+ def _send_to_resolved_session(self, session: Session, text: str, enter: bool = True) -> bool:
502
+ """Send text/keys to an already-resolved session.
503
+
504
+ Internal helper shared by send_to_session() and send_to_session_by_id().
505
+ """
470
506
  # Handle special keys
471
507
  special_keys = {
472
508
  "enter": "", # Empty string + Enter = just press Enter
@@ -63,6 +63,7 @@ from .status_constants import (
63
63
  is_green_status,
64
64
  )
65
65
  from .status_detector import StatusDetector
66
+ from .status_patterns import extract_pr_number
66
67
  from .status_detector_factory import StatusDetectorDispatcher
67
68
  from .status_history import log_agent_status
68
69
  from .monitor_daemon_core import (
@@ -191,7 +192,7 @@ class PresenceComponent:
191
192
  self._last_publish_time = now
192
193
  return False
193
194
  gap = (now - self._last_publish_time).total_seconds()
194
- slept = gap > 2 * DAEMON.interval_fast
195
+ slept = gap > 20 # Machine sleep produces gaps of 30s+; absolute threshold avoids false positives
195
196
  self._last_publish_time = now
196
197
  return slept
197
198
 
@@ -637,7 +638,7 @@ class MonitorDaemon:
637
638
 
638
639
  def _interruptible_sleep(self, total_seconds: int) -> None:
639
640
  """Sleep with activity signal checking."""
640
- chunk_size = 10
641
+ chunk_size = 1
641
642
  elapsed = 0
642
643
 
643
644
  while elapsed < total_seconds and not self._shutdown:
@@ -849,7 +850,13 @@ class MonitorDaemon:
849
850
  status, activity = STATUS_DONE, "Completed"
850
851
  else:
851
852
  # Detect status - dispatches per-session via dispatcher (#5)
852
- status, activity, _ = self.detector.detect_status(session)
853
+ status, activity, pane_content = self.detector.detect_status(session)
854
+
855
+ # Extract PR number from pane content
856
+ if pane_content:
857
+ pr = extract_pr_number(pane_content)
858
+ if pr is not None and pr != session.pr_number:
859
+ self.session_manager.update_session(session.id, pr_number=pr)
853
860
 
854
861
  # Clear heartbeat tracking when session stops running
855
862
  if status != STATUS_RUNNING and session.id in self._sessions_running_from_heartbeat: