Skip to content

Instantly share code, notes, and snippets.

@tonydzi
Created August 24, 2026 18:04
Show Gist options
  • Select an option

  • Save tonydzi/64183d29eeaa37d7b38a3775012a8c1d to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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