Created
August 24, 2026 18:04
-
-
Save tonydzi/64183d29eeaa37d7b38a3775012a8c1d to your computer and use it in GitHub Desktop.
Audit Claude Desktop scheduled tasks: which are reusable, and which will stop on a permission prompt (reads per-task permissionMode from local session state; unions all task stores)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """claude_task_audit.py - audit Claude Desktop scheduled tasks: which ones are | |
| reusable, and which ones will stop and ask for permission on their next run. | |
| Why this exists | |
| --------------- | |
| Scheduled ("routine") tasks each carry their OWN permission mode, and the desktop | |
| UI is the only place that shows it. If a task falls back to a prompting mode, you | |
| find out hours later, from a dialog nobody clicked. Two facts make that auditable | |
| and largely avoidable, both verified by measurement (2026-08-24, Windows hub + | |
| earlier on macOS): | |
| 1. Every task-spawned session writes its own state file next to the task store: | |
| <root>/claude-code-sessions/<account>/<org>/local_<sessionId>.json | |
| and that file contains BOTH `scheduledTaskId` and `permissionMode`. So the | |
| per-task permission mode IS readable programmatically, after the task's first | |
| run - no UI needed. That is what the "spawned sessions" table below reports. | |
| 2. Tool approvals granted during a run are stored ON THE TASK and auto-applied to | |
| its future runs (the app states this itself in the create/update tool result). | |
| Consequence: a brand-new task starts cold and re-asks; REUSING one task that | |
| has already been approved does not. Updating an existing task | |
| (prompt + description + fireAt) is a much quieter path than creating a new one | |
| for every background job. | |
| Reuse does not cost you visibility: a reused task's session can rename itself | |
| on its first turn (set_session_title), so it shows up under a meaningful name | |
| rather than the old task's id. | |
| 3. Task registrations live in SEVERAL stores, not one. Readers that pick the | |
| newest file by mtime see only a slice (measured: 17 of 90 tasks). The newest | |
| store is "where the app wrote last", not "everything that is registered". | |
| This script unions them. | |
| Usage: python3 claude_task_audit.py | |
| Stdlib only, read-only, no network. Public domain / CC0. | |
| """ | |
| import datetime | |
| import glob | |
| import json | |
| import os | |
| STORE = "scheduled-tasks.json" | |
| def store_roots(): | |
| home = os.path.expanduser("~") | |
| roots = [] | |
| if os.name == "nt": | |
| # Claude Desktop ships as an MSIX package on Windows: ~/AppData/Roaming/Claude | |
| # is a package redirect that is invisible to processes outside the package | |
| # (Task Scheduler, cron-like runners). Probe the physical path first. | |
| pkgs = os.path.join(home, "AppData", "Local", "Packages") | |
| try: | |
| for name in sorted(os.listdir(pkgs)): | |
| if name.startswith("Claude"): | |
| roots.append(os.path.join(pkgs, name, "LocalCache", "Roaming", | |
| "Claude", "claude-code-sessions")) | |
| except OSError: | |
| pass | |
| if os.environ.get("APPDATA"): | |
| roots.append(os.path.join(os.environ["APPDATA"], "Claude", "claude-code-sessions")) | |
| roots += [ | |
| os.path.join(home, "Library", "Application Support", "Claude", "claude-code-sessions"), | |
| os.path.join(home, "AppData", "Roaming", "Claude", "claude-code-sessions"), | |
| os.path.join(home, ".config", "Claude", "claude-code-sessions"), | |
| ] | |
| out, seen = [], set() | |
| for r in roots: | |
| n = os.path.normpath(r) | |
| if n not in seen: | |
| seen.add(n) | |
| out.append(n) | |
| return out | |
| def store_paths(): | |
| found, seen = [], set() | |
| for root in store_roots(): | |
| for p in glob.glob(os.path.join(root, "*", "*", STORE)): | |
| real = os.path.realpath(p) | |
| if real not in seen: | |
| seen.add(real) | |
| found.append(p) | |
| return found | |
| def load_tasks(): | |
| """Union of every store, keyed by task id; newest lastRunAt wins on collision.""" | |
| merged = {} | |
| for p in store_paths(): | |
| try: | |
| with open(p, encoding="utf-8") as f: | |
| data = json.load(f) | |
| except Exception: | |
| continue # half-written store: skip, do not crash | |
| for t in data.get("scheduledTasks", []): | |
| prev = merged.get(t.get("id")) | |
| if prev is None or str(t.get("lastRunAt") or "") > str(prev.get("lastRunAt") or ""): | |
| merged[t["id"]] = t | |
| return merged | |
| def spawned_sessions(): | |
| """task id -> (permissionMode, title) for every session a task spawned.""" | |
| out = {} | |
| for p in store_paths(): | |
| for s in glob.glob(os.path.join(os.path.dirname(p), "local_*.json")): | |
| try: | |
| with open(s, encoding="utf-8") as f: | |
| d = json.load(f) | |
| except Exception: | |
| continue | |
| if d.get("scheduledTaskId"): | |
| out[d["scheduledTaskId"]] = (d.get("permissionMode"), d.get("title")) | |
| return out | |
| def main(): | |
| paths = store_paths() | |
| if not paths: | |
| print("No task stores found - Claude Desktop not installed here, or it moved.") | |
| print("Roots probed:") | |
| for r in store_roots(): | |
| print(" ", r) | |
| return 2 | |
| tasks = load_tasks() | |
| sess = spawned_sessions() | |
| print("stores: %d tasks: %d" % (len(paths), len(tasks))) | |
| for p in paths: | |
| print(" %s" % p) | |
| reusable, live = [], [] | |
| for t in tasks.values(): | |
| if t.get("cronExpression") and t.get("enabled"): | |
| live.append(t) | |
| elif not t.get("enabled") and (t.get("lastRunAt") or t.get("fireAt")): | |
| reusable.append(t) | |
| print("\nREUSABLE (already ran, disabled) - update these instead of creating new ones:") | |
| for t in sorted(reusable, key=lambda x: str(x.get("lastRunAt") or "")): | |
| mode, title = sess.get(t["id"], (None, None)) | |
| print(" %-42s last run %s last session mode: %s" | |
| % (t["id"], str(t.get("lastRunAt"))[:19], mode or "unknown")) | |
| if not reusable: | |
| print(" (none)") | |
| print("\nSPAWNED SESSIONS - anything not 'bypassPermissions' will stop and ask:") | |
| bad = [(k, v) for k, v in sess.items() if v[0] != "bypassPermissions"] | |
| for k, (mode, title) in sorted(sess.items()): | |
| flag = " " if mode == "bypassPermissions" else "!!" | |
| print(" %s %-42s %s" % (flag, k, mode)) | |
| if not sess: | |
| print(" (no task has run yet on this machine)") | |
| print("\nverdict: %s" % ("OK" if not bad else | |
| "%d task(s) will prompt on their next run" % len(bad))) | |
| print("stamped: %s" % datetime.datetime.now().astimezone().isoformat(timespec="seconds")) | |
| return 1 if bad else 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment