overcode 0.1.9__tar.gz → 0.2.0__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 (94) hide show
  1. {overcode-0.1.9/src/overcode.egg-info → overcode-0.2.0}/PKG-INFO +1 -1
  2. {overcode-0.1.9 → overcode-0.2.0}/pyproject.toml +1 -1
  3. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/claude_config.py +47 -0
  4. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/cli.py +150 -0
  5. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/history_reader.py +73 -21
  6. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/launcher.py +44 -2
  7. overcode-0.2.0/src/overcode/notifier.py +145 -0
  8. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/settings.py +3 -0
  9. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/summary_columns.py +7 -6
  10. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/summary_groups.py +9 -0
  11. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/time_context.py +3 -1
  12. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui.py +56 -1
  13. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/input.py +21 -5
  14. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/view.py +19 -3
  15. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_render.py +2 -1
  16. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/session_summary.py +3 -3
  17. {overcode-0.1.9 → overcode-0.2.0/src/overcode.egg-info}/PKG-INFO +1 -1
  18. {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/SOURCES.txt +1 -0
  19. {overcode-0.1.9 → overcode-0.2.0}/LICENSE +0 -0
  20. {overcode-0.1.9 → overcode-0.2.0}/MANIFEST.in +0 -0
  21. {overcode-0.1.9 → overcode-0.2.0}/README.md +0 -0
  22. {overcode-0.1.9 → overcode-0.2.0}/setup.cfg +0 -0
  23. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/__init__.py +0 -0
  24. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/bundled_skills.py +0 -0
  25. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/config.py +0 -0
  26. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/daemon_claude_skill.md +0 -0
  27. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/daemon_logging.py +0 -0
  28. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/daemon_utils.py +0 -0
  29. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/data_export.py +0 -0
  30. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/dependency_check.py +0 -0
  31. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/exceptions.py +0 -0
  32. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/follow_mode.py +0 -0
  33. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/hook_handler.py +0 -0
  34. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/hook_status_detector.py +0 -0
  35. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/implementations.py +0 -0
  36. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/interfaces.py +0 -0
  37. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/logging_config.py +0 -0
  38. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/mocks.py +0 -0
  39. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/monitor_daemon.py +0 -0
  40. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/monitor_daemon_core.py +0 -0
  41. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/monitor_daemon_state.py +0 -0
  42. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/pid_utils.py +0 -0
  43. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/presence_logger.py +0 -0
  44. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/protocols.py +0 -0
  45. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/session_manager.py +0 -0
  46. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/sister_controller.py +0 -0
  47. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/sister_poller.py +0 -0
  48. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/standing_instructions.py +0 -0
  49. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_constants.py +0 -0
  50. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_detector.py +0 -0
  51. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_detector_factory.py +0 -0
  52. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_history.py +0 -0
  53. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_patterns.py +0 -0
  54. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/summarizer_client.py +0 -0
  55. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/summarizer_component.py +0 -0
  56. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/supervisor_daemon.py +0 -0
  57. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/supervisor_daemon_core.py +0 -0
  58. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/supervisor_layout.sh +0 -0
  59. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/__init__.py +0 -0
  60. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/renderer.py +0 -0
  61. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/tmux_driver.py +0 -0
  62. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/tui_eye.py +0 -0
  63. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/tui_eye_skill.md +0 -0
  64. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tmux_manager.py +0 -0
  65. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tmux_utils.py +0 -0
  66. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui.tcss +0 -0
  67. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/__init__.py +0 -0
  68. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/daemon.py +0 -0
  69. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/navigation.py +0 -0
  70. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/session.py +0 -0
  71. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_formatters.py +0 -0
  72. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_helpers.py +0 -0
  73. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_logic.py +0 -0
  74. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/__init__.py +0 -0
  75. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/command_bar.py +0 -0
  76. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/daemon_panel.py +0 -0
  77. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
  78. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
  79. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/help_overlay.py +0 -0
  80. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/preview_pane.py +0 -0
  81. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/status_timeline.py +0 -0
  82. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
  83. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/usage_monitor.py +0 -0
  84. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_api.py +0 -0
  85. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_chartjs.py +0 -0
  86. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_control_api.py +0 -0
  87. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_server.py +0 -0
  88. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_server_runner.py +0 -0
  89. {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_templates.py +0 -0
  90. {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/dependency_links.txt +0 -0
  91. {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/entry_points.txt +0 -0
  92. {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/requires.txt +0 -0
  93. {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/top_level.txt +0 -0
  94. {overcode-0.1.9 → overcode-0.2.0}/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.1.9
3
+ Version: 0.2.0
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.1.9"
7
+ version = "0.2.0"
8
8
  description = "A supervisor for managing multiple Claude Code instances in tmux"
9
9
  authors = [
10
10
  {name = "Mike Bond"}
@@ -137,3 +137,50 @@ class ClaudeConfigEditor:
137
137
  if cmd.startswith(command_prefix):
138
138
  results.append((event, cmd))
139
139
  return results
140
+
141
+ # ----- Permission management -----
142
+
143
+ def add_permission(self, tool_pattern: str) -> bool:
144
+ """Add to permissions.allow. Returns True if newly added."""
145
+ settings = self.load()
146
+ allow_list = settings.get("permissions", {}).get("allow", [])
147
+ if tool_pattern in allow_list:
148
+ return False
149
+
150
+ updated = copy.deepcopy(settings)
151
+ if "permissions" not in updated:
152
+ updated["permissions"] = {}
153
+ if "allow" not in updated["permissions"]:
154
+ updated["permissions"]["allow"] = []
155
+ updated["permissions"]["allow"].append(tool_pattern)
156
+ self.save(updated)
157
+ return True
158
+
159
+ def remove_permission(self, tool_pattern: str) -> bool:
160
+ """Remove from permissions.allow. Returns True if found."""
161
+ settings = self.load()
162
+ allow_list = settings.get("permissions", {}).get("allow", [])
163
+ if tool_pattern not in allow_list:
164
+ return False
165
+
166
+ updated = copy.deepcopy(settings)
167
+ updated["permissions"]["allow"].remove(tool_pattern)
168
+
169
+ # Clean up empty allow list
170
+ if not updated["permissions"]["allow"]:
171
+ del updated["permissions"]["allow"]
172
+
173
+ # Clean up empty permissions dict
174
+ if not updated["permissions"]:
175
+ del updated["permissions"]
176
+
177
+ self.save(updated)
178
+ return True
179
+
180
+ def list_permissions_matching(self, prefix: str) -> list[str]:
181
+ """Return entries from permissions.allow that start with prefix."""
182
+ settings = self.load()
183
+ return [
184
+ p for p in settings.get("permissions", {}).get("allow", [])
185
+ if p.startswith(prefix)
186
+ ]
@@ -56,6 +56,14 @@ skills_app = typer.Typer(
56
56
  )
57
57
  app.add_typer(skills_app, name="skills")
58
58
 
59
+ # Perms subcommand group
60
+ perms_app = typer.Typer(
61
+ name="perms",
62
+ help="Manage Claude Code tool permissions for overcode commands.",
63
+ no_args_is_help=True,
64
+ )
65
+ app.add_typer(perms_app, name="perms")
66
+
59
67
  # Budget subcommand group (#244)
60
68
  budget_app = typer.Typer(
61
69
  name="budget",
@@ -1265,6 +1273,148 @@ def skills_status():
1265
1273
  rprint(f" {name:<20} [yellow]modified[/yellow]")
1266
1274
 
1267
1275
 
1276
+ # =============================================================================
1277
+ # Perms Commands
1278
+ # =============================================================================
1279
+
1280
+ OVERCODE_SAFE_PERMS = [
1281
+ "Bash(overcode report *)",
1282
+ "Bash(overcode show *)",
1283
+ "Bash(overcode list *)",
1284
+ "Bash(overcode follow *)",
1285
+ "Bash(overcode kill *)",
1286
+ "Bash(overcode budget *)",
1287
+ ]
1288
+
1289
+ OVERCODE_PUNCHY_PERMS = [
1290
+ "Bash(overcode launch *)",
1291
+ "Bash(overcode send *)",
1292
+ "Bash(overcode instruct *)",
1293
+ ]
1294
+
1295
+
1296
+ @perms_app.command("install")
1297
+ def perms_install(
1298
+ project: Annotated[
1299
+ bool,
1300
+ typer.Option("--project", "-p", help="Install to project-level .claude/settings.json instead of user-level"),
1301
+ ] = False,
1302
+ all_perms: Annotated[
1303
+ bool,
1304
+ typer.Option("--all", help="Include punchy permissions (launch, send, instruct)"),
1305
+ ] = False,
1306
+ ):
1307
+ """Install overcode tool permissions into Claude Code settings.
1308
+
1309
+ By default installs safe (read-only) permissions. Use --all to also
1310
+ include punchy permissions that can spawn or control agents.
1311
+
1312
+ Safe: report, show, list, follow, kill, budget
1313
+ Punchy (--all): launch, send, instruct
1314
+ """
1315
+ from .claude_config import ClaudeConfigEditor
1316
+
1317
+ if project:
1318
+ editor = ClaudeConfigEditor.project_level()
1319
+ level = "project"
1320
+ else:
1321
+ editor = ClaudeConfigEditor.user_level()
1322
+ level = "user"
1323
+
1324
+ try:
1325
+ editor.load()
1326
+ except ValueError as e:
1327
+ rprint(f"[red]Error:[/red] {e}")
1328
+ raise typer.Exit(1)
1329
+
1330
+ perms = OVERCODE_SAFE_PERMS + (OVERCODE_PUNCHY_PERMS if all_perms else [])
1331
+
1332
+ installed = 0
1333
+ already = 0
1334
+ for perm in perms:
1335
+ if editor.add_permission(perm):
1336
+ installed += 1
1337
+ else:
1338
+ already += 1
1339
+
1340
+ if installed > 0:
1341
+ tier = "safe + punchy" if all_perms else "safe"
1342
+ rprint(f"[green]\u2713[/green] Installed {installed} permission(s) in {level} settings ({tier})")
1343
+ rprint(f" [dim]{editor.path}[/dim]")
1344
+ for perm in perms:
1345
+ rprint(f" {perm}")
1346
+ elif already == len(perms):
1347
+ rprint(f"[green]\u2713[/green] All {already} permissions already installed in {level} settings")
1348
+
1349
+
1350
+ @perms_app.command("uninstall")
1351
+ def perms_uninstall(
1352
+ project: Annotated[
1353
+ bool,
1354
+ typer.Option("--project", "-p", help="Uninstall from project-level .claude/settings.json instead of user-level"),
1355
+ ] = False,
1356
+ ):
1357
+ """Remove all overcode permissions from Claude Code settings."""
1358
+ from .claude_config import ClaudeConfigEditor
1359
+
1360
+ if project:
1361
+ editor = ClaudeConfigEditor.project_level()
1362
+ level = "project"
1363
+ else:
1364
+ editor = ClaudeConfigEditor.user_level()
1365
+ level = "user"
1366
+
1367
+ try:
1368
+ editor.load()
1369
+ except ValueError as e:
1370
+ rprint(f"[red]Error:[/red] {e}")
1371
+ raise typer.Exit(1)
1372
+
1373
+ all_perms = OVERCODE_SAFE_PERMS + OVERCODE_PUNCHY_PERMS
1374
+ removed = 0
1375
+ for perm in all_perms:
1376
+ if editor.remove_permission(perm):
1377
+ removed += 1
1378
+
1379
+ if removed > 0:
1380
+ rprint(f"[green]\u2713[/green] Removed {removed} permission(s) from {level} settings")
1381
+ else:
1382
+ rprint(f"[dim]No overcode permissions found in {level} settings[/dim]")
1383
+
1384
+
1385
+ @perms_app.command("status")
1386
+ def perms_status():
1387
+ """Show which overcode permissions are installed."""
1388
+ from .claude_config import ClaudeConfigEditor
1389
+
1390
+ all_perms = OVERCODE_SAFE_PERMS + OVERCODE_PUNCHY_PERMS
1391
+
1392
+ for level_name, editor in [
1393
+ ("User-level", ClaudeConfigEditor.user_level()),
1394
+ ("Project-level", ClaudeConfigEditor.project_level()),
1395
+ ]:
1396
+ try:
1397
+ editor.load()
1398
+ except ValueError:
1399
+ rprint(f"\n{level_name} ({editor.path}):")
1400
+ rprint(f" [red](invalid JSON)[/red]")
1401
+ continue
1402
+
1403
+ if not editor.path.exists():
1404
+ rprint(f"\n{level_name} ({editor.path}):")
1405
+ rprint(f" [dim](no settings file)[/dim]")
1406
+ continue
1407
+
1408
+ rprint(f"\n{level_name} ({editor.path}):")
1409
+
1410
+ installed = editor.list_permissions_matching("Bash(overcode ")
1411
+ for perm in all_perms:
1412
+ if perm in installed:
1413
+ rprint(f" {perm} [green]\u2713[/green]")
1414
+ else:
1415
+ rprint(f" {perm:<30} [dim]not installed[/dim]")
1416
+
1417
+
1268
1418
  @app.command("hook-handler", hidden=True)
1269
1419
  def hook_handler_cmd():
1270
1420
  """Handle Claude Code hook events (internal).
@@ -32,6 +32,30 @@ if TYPE_CHECKING:
32
32
  CLAUDE_HISTORY_PATH = Path.home() / ".claude" / "history.jsonl"
33
33
  CLAUDE_PROJECTS_PATH = Path.home() / ".claude" / "projects"
34
34
 
35
+ # Model name → context window size in tokens.
36
+ # Default 200K for unknown models. Update as new models ship.
37
+ MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
38
+ "claude-opus-4-6": 200_000,
39
+ "claude-sonnet-4-5-20250929": 200_000,
40
+ "claude-haiku-4-5-20251001": 200_000,
41
+ "claude-3-5-sonnet-20241022": 200_000,
42
+ "claude-3-5-haiku-20241022": 200_000,
43
+ "claude-3-opus-20240229": 200_000,
44
+ "claude-3-sonnet-20240229": 200_000,
45
+ "claude-3-haiku-20240307": 200_000,
46
+ }
47
+ DEFAULT_CONTEXT_WINDOW = 200_000
48
+
49
+
50
+ def model_context_window(model: Optional[str]) -> int:
51
+ """Return the context window size for a given model name.
52
+
53
+ Falls back to DEFAULT_CONTEXT_WINDOW for unknown/None models.
54
+ """
55
+ if not model:
56
+ return DEFAULT_CONTEXT_WINDOW
57
+ return MODEL_CONTEXT_WINDOWS.get(model, DEFAULT_CONTEXT_WINDOW)
58
+
35
59
 
36
60
  @dataclass
37
61
  class ClaudeSessionStats:
@@ -46,6 +70,12 @@ class ClaudeSessionStats:
46
70
  subagent_count: int = 0 # Number of subagent files (#176)
47
71
  live_subagent_count: int = 0 # Subagents with recently-modified files (#256)
48
72
  background_task_count: int = 0 # Number of background/farm tasks (#177)
73
+ model: Optional[str] = None # Most recently seen model name (#272)
74
+
75
+ @property
76
+ def max_context_tokens(self) -> int:
77
+ """Context window size for the detected model."""
78
+ return model_context_window(self.model)
49
79
 
50
80
  @property
51
81
  def total_tokens(self) -> int:
@@ -151,7 +181,13 @@ class HistoryFile:
151
181
  def get_interactions_for_session(
152
182
  self, session: "Session"
153
183
  ) -> List[HistoryEntry]:
154
- """Get history entries matching a session's directory and time window."""
184
+ """Get history entries matching a session's directory and time window.
185
+
186
+ When the session has known claude_session_ids, filters by sessionId
187
+ to avoid cross-contamination between agents sharing a directory (#264).
188
+ Falls back to directory+timestamp matching for older sessions without
189
+ tracked sessionIds.
190
+ """
155
191
  if not session.start_directory:
156
192
  return []
157
193
 
@@ -161,13 +197,21 @@ class HistoryFile:
161
197
  except (ValueError, TypeError):
162
198
  return []
163
199
 
200
+ # Use owned sessionIds when available for precise matching (#264)
201
+ owned_ids = set(getattr(session, 'claude_session_ids', None) or [])
202
+
164
203
  session_dir = str(Path(session.start_directory).resolve())
165
204
  matching = []
166
205
 
167
206
  for entry in self._entries():
168
207
  if entry.timestamp_ms < session_start_ms:
169
208
  continue
170
- if entry.project:
209
+ if owned_ids:
210
+ # Precise: only count interactions from this session's own Claude sessions
211
+ if entry.session_id in owned_ids:
212
+ matching.append(entry)
213
+ elif entry.project:
214
+ # Fallback: directory matching for sessions without tracked IDs
171
215
  entry_dir = str(Path(entry.project).resolve())
172
216
  if entry_dir == session_dir:
173
217
  matching.append(entry)
@@ -381,6 +425,7 @@ def read_token_usage_from_session_file(
381
425
  "cache_creation_tokens": 0,
382
426
  "cache_read_tokens": 0,
383
427
  "current_context_tokens": 0, # Most recent input_tokens
428
+ "model": None, # Most recently seen model name (#272)
384
429
  }
385
430
 
386
431
  if not session_file.exists():
@@ -411,6 +456,9 @@ def read_token_usage_from_session_file(
411
456
  pass
412
457
 
413
458
  message = data.get("message", {})
459
+ model = message.get("model")
460
+ if model:
461
+ totals["model"] = model
414
462
  usage = message.get("usage", {})
415
463
  if usage:
416
464
  input_tokens = usage.get("input_tokens", 0)
@@ -520,9 +568,13 @@ def get_session_stats(
520
568
 
521
569
  Combines interaction counting with token usage from session files.
522
570
 
523
- For context window calculation, only owned sessionIds are used to avoid
524
- cross-contamination when multiple agents run in the same directory (#119).
525
- Total token counting still uses all matched sessionIds.
571
+ Session scoping: get_interactions_for_session() is the single source of
572
+ truth for which Claude Code sessions belong to this overcode session.
573
+ When claude_session_ids are tracked, it filters precisely by sessionId;
574
+ otherwise falls back to directory+timestamp matching (#119, #264).
575
+
576
+ Context window uses active_claude_session_id after /clear (#116),
577
+ falling back to MAX across all matched sessions.
526
578
 
527
579
  Args:
528
580
  session: The overcode Session
@@ -542,7 +594,8 @@ def get_session_stats(
542
594
  except (ValueError, TypeError):
543
595
  return None
544
596
 
545
- # Get interaction count and session IDs use shared HistoryFile if provided
597
+ # get_interactions_for_session is the single gate for session scoping:
598
+ # uses claude_session_ids when available, else directory+timestamp fallback
546
599
  hf = history_file or (
547
600
  _default_history if history_path == CLAUDE_HISTORY_PATH
548
601
  else HistoryFile(history_path)
@@ -550,32 +603,26 @@ def get_session_stats(
550
603
  interactions = hf.get_interactions_for_session(session)
551
604
  interaction_count = len(interactions)
552
605
 
553
- # Get unique session IDs from interactions (for total token counting)
554
- all_session_ids = set()
555
- for entry in interactions:
556
- if entry.session_id:
557
- all_session_ids.add(entry.session_id)
558
-
559
- # Get owned sessionIds for token counting (#119)
560
- owned_session_ids = getattr(session, 'claude_session_ids', None) or []
606
+ # Derive Claude sessionIds from the already-scoped interactions
607
+ session_ids = {e.session_id for e in interactions if e.session_id}
561
608
 
562
- # Get active session ID for context calculation (#116)
563
- # After /clear, only the active session's context is relevant
609
+ # Active session ID for context window after /clear (#116)
564
610
  active_session_id = getattr(session, 'active_claude_session_id', None)
565
611
 
566
- # Sum token usage and work times across all session files
612
+ # Sum token usage and work times across session files
567
613
  total_input = 0
568
614
  total_output = 0
569
615
  total_cache_creation = 0
570
616
  total_cache_read = 0
571
- current_context = 0 # Track context size from active session only (#116)
617
+ current_context = 0
618
+ detected_model: Optional[str] = None
572
619
  all_work_times: List[float] = []
573
620
  subagent_count = 0 # Count subagent files (#176)
574
621
  live_subagent_count = 0 # Subagents with recently-modified files (#256)
575
622
  background_task_count = 0 # Count background task files (#177)
576
623
  now = time.time()
577
624
 
578
- for sid in all_session_ids:
625
+ for sid in session_ids:
579
626
  session_file = get_session_file_path(
580
627
  session.start_directory, sid, projects_path
581
628
  )
@@ -585,13 +632,17 @@ def get_session_stats(
585
632
  total_cache_creation += usage["cache_creation_tokens"]
586
633
  total_cache_read += usage["cache_read_tokens"]
587
634
 
588
- # Context: use only the active session (#116), fall back to MAX of owned (#119)
635
+ # Context & model: prefer active session (#116), fall back to MAX across all
589
636
  if active_session_id:
590
637
  if sid == active_session_id:
591
638
  current_context = usage["current_context_tokens"]
592
- elif sid in owned_session_ids:
639
+ if usage["model"]:
640
+ detected_model = usage["model"]
641
+ else:
593
642
  if usage["current_context_tokens"] > current_context:
594
643
  current_context = usage["current_context_tokens"]
644
+ if usage["model"]:
645
+ detected_model = usage["model"]
595
646
 
596
647
  # Collect work times from this session file
597
648
  work_times = read_work_times_from_session_file(session_file, since=session_start)
@@ -630,4 +681,5 @@ def get_session_stats(
630
681
  subagent_count=subagent_count,
631
682
  live_subagent_count=live_subagent_count,
632
683
  background_task_count=background_task_count,
684
+ model=detected_model,
633
685
  )
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
  import re
17
17
 
18
18
  from .tmux_manager import TmuxManager
19
- from .tmux_utils import send_text_to_tmux_window
19
+ from .tmux_utils import send_text_to_tmux_window, get_tmux_pane_content
20
20
  from .session_manager import SessionManager, Session
21
21
  from .config import get_default_standing_instructions
22
22
  from .dependency_check import require_tmux, require_claude
@@ -210,13 +210,55 @@ class ClaudeLauncher:
210
210
 
211
211
  return session
212
212
 
213
+ # Characters that indicate Claude's input prompt is ready
214
+ PROMPT_READY_CHARS = {">", "›", "❯"}
215
+
216
+ def _wait_for_prompt(
217
+ self,
218
+ window_index: int,
219
+ timeout: float = 30.0,
220
+ poll_interval: float = 0.5,
221
+ ) -> bool:
222
+ """Poll pane content until Claude's input prompt appears.
223
+
224
+ Returns True if prompt detected, False on timeout.
225
+ """
226
+ from .status_patterns import strip_ansi
227
+
228
+ deadline = time.time() + timeout
229
+ while time.time() < deadline:
230
+ content = get_tmux_pane_content(
231
+ self.tmux.session_name, window_index, lines=5
232
+ )
233
+ if content:
234
+ for line in content.split('\n'):
235
+ cleaned = strip_ansi(line).strip()
236
+ if cleaned in self.PROMPT_READY_CHARS:
237
+ return True
238
+ time.sleep(poll_interval)
239
+ return False
240
+
213
241
  def _send_prompt_to_window(
214
242
  self,
215
243
  window_index: int,
216
244
  prompt: str,
217
245
  startup_delay: float = 3.0,
218
246
  ) -> bool:
219
- """Send a prompt to a Claude session via tmux load-buffer/paste-buffer."""
247
+ """Send a prompt to a Claude session via tmux load-buffer/paste-buffer.
248
+
249
+ Polls for Claude's input prompt before sending. Falls back to
250
+ startup_delay if the prompt is not detected within 30 seconds.
251
+ """
252
+ if self._wait_for_prompt(window_index):
253
+ # Prompt detected — send immediately, no delay needed
254
+ return send_text_to_tmux_window(
255
+ self.tmux.session_name,
256
+ window_index,
257
+ prompt,
258
+ send_enter=True,
259
+ startup_delay=0,
260
+ )
261
+ # Fallback: prompt not detected, use original delay
220
262
  return send_text_to_tmux_window(
221
263
  self.tmux.session_name,
222
264
  window_index,
@@ -0,0 +1,145 @@
1
+ """
2
+ macOS notification integration for agent bells (#235).
3
+
4
+ Sends native notifications when agents transition to waiting_user state,
5
+ so users working in other apps can hear/see when agents need attention.
6
+ """
7
+
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+
13
+
14
+ class MacNotifier:
15
+ """Coalescing macOS notifier for agent attention bells.
16
+
17
+ Queues agent names during a status update cycle, then flushes a single
18
+ coalesced notification at the end. Uses terminal-notifier when available
19
+ (supports grouping/replacement), falling back to osascript.
20
+ """
21
+
22
+ MODES = ("off", "sound", "banner", "both")
23
+
24
+ def __init__(self, mode: str = "off", coalesce_seconds: float = 2.0):
25
+ self.mode = mode if mode in self.MODES else "off"
26
+ self.coalesce_seconds = coalesce_seconds
27
+ self._pending: list[tuple[str, str | None]] = [] # (name, task)
28
+ self._last_send: float = 0.0
29
+ self._has_terminal_notifier: bool | None = None # lazy-detected
30
+
31
+ def queue(self, agent_name: str, task: str | None = None) -> None:
32
+ """Queue an agent bell for the current cycle. No-ops when off or non-darwin."""
33
+ if self.mode == "off" or sys.platform != "darwin":
34
+ return
35
+ self._pending.append((agent_name, task))
36
+
37
+ def flush(self) -> None:
38
+ """Send a coalesced notification for all queued bells, then clear."""
39
+ if not self._pending or self.mode == "off" or sys.platform != "darwin":
40
+ self._pending.clear()
41
+ return
42
+
43
+ now = time.monotonic()
44
+ if now - self._last_send < self.coalesce_seconds:
45
+ # Too soon — hold until next cycle
46
+ return
47
+
48
+ names = [name for name, _ in self._pending]
49
+ task = self._pending[0][1] if len(self._pending) == 1 else None
50
+
51
+ subtitle, message = self._format(names, task)
52
+ self._send(message, subtitle)
53
+
54
+ self._last_send = now
55
+ self._pending.clear()
56
+
57
+ # ------------------------------------------------------------------
58
+ # Formatting
59
+ # ------------------------------------------------------------------
60
+
61
+ @staticmethod
62
+ def _format(names: list[str], task: str | None) -> tuple[str | None, str]:
63
+ """Return (subtitle, message) for the notification.
64
+
65
+ subtitle is only set for single-agent + task (used by terminal-notifier).
66
+ """
67
+ if len(names) == 1:
68
+ msg = f"{names[0]} needs attention"
69
+ if task:
70
+ return msg, task
71
+ return None, msg
72
+ elif len(names) == 2:
73
+ return None, f"{names[0]} and {names[1]} need attention"
74
+ elif len(names) == 3:
75
+ return None, f"{names[0]}, {names[1]}, and {names[2]} need attention"
76
+ else:
77
+ others = len(names) - 2
78
+ return None, f"{names[0]}, {names[1]}, and {others} others need attention"
79
+
80
+ # ------------------------------------------------------------------
81
+ # Dispatch
82
+ # ------------------------------------------------------------------
83
+
84
+ def _send(self, message: str, subtitle: str | None = None) -> None:
85
+ """Fire-and-forget a macOS notification via best available backend."""
86
+ want_sound = self.mode in ("sound", "both")
87
+ want_banner = self.mode in ("banner", "both")
88
+
89
+ if self._use_terminal_notifier():
90
+ self._send_terminal_notifier(message, subtitle, want_sound, want_banner)
91
+ else:
92
+ self._send_osascript(message, subtitle, want_sound, want_banner)
93
+
94
+ def _use_terminal_notifier(self) -> bool:
95
+ if self._has_terminal_notifier is None:
96
+ self._has_terminal_notifier = shutil.which("terminal-notifier") is not None
97
+ return self._has_terminal_notifier
98
+
99
+ def _send_terminal_notifier(
100
+ self, message: str, subtitle: str | None,
101
+ want_sound: bool, want_banner: bool,
102
+ ) -> None:
103
+ cmd = ["terminal-notifier", "-title", "Overcode", "-group", "overcode-bell"]
104
+ if subtitle:
105
+ cmd += ["-subtitle", subtitle]
106
+ cmd += ["-message", message]
107
+ if want_sound:
108
+ cmd += ["-sound", "Hero"]
109
+ if not want_banner:
110
+ # terminal-notifier doesn't have a silent-banner flag;
111
+ # skip entirely if only sound is wanted
112
+ if not want_sound:
113
+ return
114
+ # sound-only: we still call terminal-notifier for the sound,
115
+ # but use a minimal notification that auto-dismisses
116
+ try:
117
+ subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
118
+ except OSError:
119
+ pass
120
+
121
+ def _send_osascript(
122
+ self, message: str, subtitle: str | None,
123
+ want_sound: bool, want_banner: bool,
124
+ ) -> None:
125
+ if want_banner:
126
+ display_text = f"{subtitle}\\n{message}" if subtitle else message
127
+ script = f'display notification "{display_text}" with title "Overcode"'
128
+ if want_sound:
129
+ script += ' sound name "Hero"'
130
+ try:
131
+ subprocess.Popen(
132
+ ["osascript", "-e", script],
133
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
134
+ )
135
+ except OSError:
136
+ pass
137
+ elif want_sound:
138
+ # Sound-only via afplay (no banner)
139
+ try:
140
+ subprocess.Popen(
141
+ ["afplay", "/System/Library/Sounds/Hero.aiff"],
142
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
143
+ )
144
+ except OSError:
145
+ pass
@@ -407,6 +407,7 @@ class TUIPreferences:
407
407
  monochrome: bool = False # B&W mode for terminals with ANSI issues (#138)
408
408
  show_cost: bool = False # Show $ cost instead of token counts
409
409
  timeline_hours: float = 3.0 # 1, 3, 6, 12, 24 — timeline scope (#191)
410
+ notifications: str = "off" # "off", "sound", "banner", "both" — macOS notifications (#235)
410
411
  # Session IDs of stalled agents that have been visited by the user
411
412
  visited_stalled_agents: Set[str] = field(default_factory=set)
412
413
  # Column group visibility (group_id -> enabled) for summary line (#178)
@@ -455,6 +456,7 @@ class TUIPreferences:
455
456
  visited_stalled_agents=set(data.get("visited_stalled_agents", [])),
456
457
  summary_groups=data.get("summary_groups", default_summary_groups),
457
458
  timeline_hours=data.get("timeline_hours", 3.0),
459
+ notifications=data.get("notifications", "off"),
458
460
  )
459
461
  except (json.JSONDecodeError, IOError):
460
462
  return cls()
@@ -485,6 +487,7 @@ class TUIPreferences:
485
487
  "visited_stalled_agents": list(self.visited_stalled_agents),
486
488
  "summary_groups": self.summary_groups,
487
489
  "timeline_hours": self.timeline_hours,
490
+ "notifications": self.notifications,
488
491
  }, f, indent=2)
489
492
  except (IOError, OSError):
490
493
  pass # Best effort