From 062130b43ea0fab2325bc7b6c94cd81c18dded28 Mon Sep 17 00:00:00 2001 From: Luca Trisiello Date: Mon, 10 Aug 2026 16:55:49 +0200 Subject: [PATCH 1/2] fix: bound /api/settings/status probes so the settings panel loads The endpoint hung long enough to look broken. Each osascript call was already bounded, but the endpoint's total was not: probing Calendar went through get_todays_events(), which on a cold cache fans out over every calendar in batches of two at 15s apiece. On a 10-calendar account that alone runs to 75s, before Mail (20s) and Notes (15s) are even reached. Prior to the user granting TCC permission, these block on a system dialog instead. Add _probe_integration(), which caps each probe at 8s and reports a slow integration the same as a broken one, and run the three concurrently -- they touch independent apps, so the endpoint now costs one budget rather than three. Probe Calendar with get_calendar_names() instead: a single already bounded osascript that answers the question a status endpoint actually asks ("is Calendar reachable?"). Capping the wait alone would have left the endpoint honest about its deadline but wrong about the result -- returning calendar_accessible: false for a Calendar that works fine. Measured: response drops from hanging (>60s) to 0.28s warm / 2.3s cold, and calendar_accessible now reports true. Co-Authored-By: Claude Opus 5 --- server.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/server.py b/server.py index f08e7370..1b25c6f3 100644 --- a/server.py +++ b/server.py @@ -42,7 +42,7 @@ from actions import execute_action, monitor_build, open_terminal, open_browser, open_claude_in_project, _generate_project_name, prompt_existing_terminal, applescript_escape from work_mode import WorkSession, is_casual_question from screen import get_active_windows, take_screenshot, describe_screen, format_windows_for_context -from calendar_access import get_todays_events, get_upcoming_events, get_next_event, format_events_for_context, format_schedule_summary, refresh_cache as refresh_calendar_cache +from calendar_access import get_todays_events, get_upcoming_events, get_next_event, get_calendar_names, format_events_for_context, format_schedule_summary, refresh_cache as refresh_calendar_cache from mail_access import get_unread_count, get_unread_messages, get_recent_messages, search_mail, read_message, format_unread_summary, format_messages_for_context, format_messages_for_voice from memory import ( remember, recall, get_open_tasks, create_task, complete_task, search_tasks, @@ -67,6 +67,9 @@ USER_NAME = os.getenv("USER_NAME", "sir") PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) _SKIP_PERMISSIONS = os.getenv("JARVIS_SKIP_PERMISSIONS", "true").lower() not in ("0", "false", "no") +# Per-integration ceiling for the /api/settings/status probes, so the settings +# panel stays responsive when Calendar/Mail/Notes are slow or unauthorized. +_SETTINGS_PROBE_TIMEOUT = 8.0 DESKTOP_PATH = Path.home() / "Desktop" @@ -2530,18 +2533,35 @@ async def api_test_fish(body: KeyTest): except Exception as e: return {"valid": False, "error": str(e)[:200]} +async def _probe_integration(coro, ok=lambda _: True, timeout: float = _SETTINGS_PROBE_TIMEOUT) -> bool: + """Return whether a macOS integration answered within the time budget. + + Individual osascript calls are already bounded, but the totals are not, and + before the user grants TCC permission they block on a system dialog. The + settings panel only needs a reachable/unreachable flag, so cap the wait and + report a slow integration the same as a broken one. + """ + try: + return bool(ok(await asyncio.wait_for(coro, timeout=timeout))) + except Exception: + return False + + @app.get("/api/settings/status") async def api_settings_status(): import shutil as _shutil _, env_dict = _read_env() claude_installed = _shutil.which("claude") is not None - calendar_ok = mail_ok = notes_ok = False - try: await get_todays_events(); calendar_ok = True - except Exception: pass - try: await get_unread_count(); mail_ok = True - except Exception: pass - try: await get_recent_notes(count=1); notes_ok = True - except Exception: pass + # Probed concurrently — they touch three separate apps and don't depend on + # each other, so the endpoint costs one budget rather than three. + # Calendar is probed by listing names rather than by fetching today's + # events: the latter fans a cold cache out over every calendar in batches + # of two, which alone outruns the budget on a well-populated account. + calendar_ok, mail_ok, notes_ok = await asyncio.gather( + _probe_integration(get_calendar_names(), ok=bool), + _probe_integration(get_unread_count()), + _probe_integration(get_recent_notes(count=1)), + ) memory_count = task_count = 0 try: memory_count = len(get_important_memories(limit=9999)) except Exception: pass From 16755e3dee3dac3b6ba7cfa80ee395cbfe50c7cc Mon Sep 17 00:00:00 2001 From: Luca Trisiello Date: Mon, 10 Aug 2026 17:11:34 +0200 Subject: [PATCH 2/2] fix: stop one unreadable note from emptying the notes listing get_recent_notes() read note properties unguarded, so a single note that Notes.app refuses to describe aborted the whole AppleScript and the function returned []. On this library, note p627 did exactly that. Guard each note individually and keep scanning until `count` readable notes are collected, bounded so a run of bad notes can't walk an entire library. The folder lookup gets its own guard. `name of container of note` turns out to fail with -1728 for every note on recent macOS -- the container resolves, but as a bare `item` whose name is not exposed -- so "folder" is empty in practice. Real folder names are only reachable by walking `every note of folder`, which scans the whole library on every call; not worth it while no caller reads the field. Guarding rather than dropping the lookup keeps it self-healing if Apple restores the property. Also probe Notes with get_note_folders() in /api/settings/status, and treat an empty result as a failure. Both notes_access and calendar_access answer an unreachable app with the same empty value they use for "nothing found", so a probe that only caught raised exceptions reported Notes as accessible while its script was failing on every call. Co-Authored-By: Claude Opus 5 --- notes_access.py | 41 ++++++++++++++++++++++++++++++++--------- server.py | 15 ++++++++++----- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/notes_access.py b/notes_access.py index 1d4c06fb..426c4a43 100644 --- a/notes_access.py +++ b/notes_access.py @@ -33,19 +33,42 @@ async def _run_notes_script(script: str, timeout: float = 10) -> str: async def get_recent_notes(count: int = 10) -> list[dict]: - """Get most recent notes (title + creation date).""" + """Get most recent notes (title + creation date). + + Individual notes are read defensively, because an unguarded property + access aborts the entire script and one unreadable note would otherwise + cost every note. + + "folder" is best-effort and is currently empty in practice: on recent + macOS, `name of container of note` fails with -1728 for every note (the + container resolves, but as a bare `item` whose name is not exposed). Real + folder names are only reachable by walking `every note of folder`, which + means scanning the whole library on every call — not worth it while no + caller reads the field. Guarding the lookup rather than dropping it keeps + this self-healing if Apple restores the property. + """ + # Bound the scan so a run of unreadable notes can't walk a whole library. + scan_limit = count + 50 script = f''' tell application "Notes" set output to "" set allNotes to every note - set limit to count of allNotes - if limit > {count} then set limit to {count} - repeat with i from 1 to limit - set n to item i of allNotes - set nName to name of n - set nDate to creation date of n as string - set nFolder to name of container of n - set output to output & nName & "|||" & nDate & "|||" & nFolder & linefeed + set scanCount to count of allNotes + if scanCount > {scan_limit} then set scanCount to {scan_limit} + set collected to 0 + repeat with i from 1 to scanCount + if collected >= {count} then exit repeat + try + set n to item i of allNotes + set nName to name of n + set nDate to creation date of n as string + set nFolder to "" + try + set nFolder to name of container of n + end try + set output to output & nName & "|||" & nDate & "|||" & nFolder & linefeed + set collected to collected + 1 + end try end repeat return output end tell diff --git a/server.py b/server.py index 1b25c6f3..358e4b78 100644 --- a/server.py +++ b/server.py @@ -49,7 +49,7 @@ create_note, search_notes, get_tasks_for_date, build_memory_context, format_tasks_for_voice, extract_memories, get_important_memories, ) -from notes_access import get_recent_notes, read_note, search_notes_apple, create_apple_note +from notes_access import get_recent_notes, get_note_folders, read_note, search_notes_apple, create_apple_note from dispatch_registry import DispatchRegistry from planner import TaskPlanner, detect_planning_mode, BYPASS_PHRASES @@ -2554,13 +2554,18 @@ async def api_settings_status(): claude_installed = _shutil.which("claude") is not None # Probed concurrently — they touch three separate apps and don't depend on # each other, so the endpoint costs one budget rather than three. - # Calendar is probed by listing names rather than by fetching today's - # events: the latter fans a cold cache out over every calendar in batches - # of two, which alone outruns the budget on a well-populated account. + # + # Calendar and Notes are probed by listing containers, and their empty + # result is read as a failure. Both modules answer an unreachable app with + # the same empty value they use for "nothing found", so a probe that only + # checked for a raised exception would report an app as healthy while it + # was in fact failing. Listing also avoids the expensive paths: fetching + # today's events fans a cold cache out over every calendar in batches of + # two, which alone outruns the budget on a well-populated account. calendar_ok, mail_ok, notes_ok = await asyncio.gather( _probe_integration(get_calendar_names(), ok=bool), _probe_integration(get_unread_count()), - _probe_integration(get_recent_notes(count=1)), + _probe_integration(get_note_folders(), ok=bool), ) memory_count = task_count = 0 try: memory_count = len(get_important_memories(limit=9999))