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.
- {overcode-0.1.9/src/overcode.egg-info → overcode-0.2.0}/PKG-INFO +1 -1
- {overcode-0.1.9 → overcode-0.2.0}/pyproject.toml +1 -1
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/claude_config.py +47 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/cli.py +150 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/history_reader.py +73 -21
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/launcher.py +44 -2
- overcode-0.2.0/src/overcode/notifier.py +145 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/settings.py +3 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/summary_columns.py +7 -6
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/summary_groups.py +9 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/time_context.py +3 -1
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui.py +56 -1
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/input.py +21 -5
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/view.py +19 -3
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_render.py +2 -1
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/session_summary.py +3 -3
- {overcode-0.1.9 → overcode-0.2.0/src/overcode.egg-info}/PKG-INFO +1 -1
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/SOURCES.txt +1 -0
- {overcode-0.1.9 → overcode-0.2.0}/LICENSE +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/MANIFEST.in +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/README.md +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/setup.cfg +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/__init__.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/bundled_skills.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/config.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/daemon_claude_skill.md +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/daemon_logging.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/daemon_utils.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/data_export.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/dependency_check.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/exceptions.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/follow_mode.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/hook_handler.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/hook_status_detector.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/implementations.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/interfaces.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/logging_config.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/mocks.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/monitor_daemon.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/monitor_daemon_core.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/monitor_daemon_state.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/pid_utils.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/presence_logger.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/protocols.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/session_manager.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/sister_controller.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/sister_poller.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/standing_instructions.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_constants.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_detector.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_detector_factory.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_history.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/status_patterns.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/summarizer_client.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/summarizer_component.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/supervisor_daemon.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/supervisor_daemon_core.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/supervisor_layout.sh +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/__init__.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/renderer.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/tmux_driver.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/tui_eye.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/testing/tui_eye_skill.md +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tmux_manager.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tmux_utils.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui.tcss +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/__init__.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/daemon.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/navigation.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_actions/session.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_formatters.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_helpers.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_logic.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/__init__.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/command_bar.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/daemon_panel.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/daemon_status_bar.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/fullscreen_preview.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/help_overlay.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/preview_pane.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/status_timeline.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/tui_widgets/summary_config_modal.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/usage_monitor.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_api.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_chartjs.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_control_api.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_server.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_server_runner.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode/web_templates.py +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/dependency_links.txt +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/entry_points.txt +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/requires.txt +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/src/overcode.egg-info/top_level.txt +0 -0
- {overcode-0.1.9 → overcode-0.2.0}/tests/test_e2e_multi_agent_jokes.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
554
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|