@m13v/s4l 1.7.4-rc.4 → 1.7.4

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 (86) hide show
  1. package/README.md +134 -101
  2. package/bin/cli.js +23 -0
  3. package/mcp/dist/index.js +24 -5
  4. package/mcp/dist/runtime.js +110 -0
  5. package/mcp/dist/version.js +163 -54
  6. package/mcp/dist/version.json +2 -2
  7. package/mcp/manifest.json +1 -1
  8. package/mcp/menubar/s4l_card.py +55 -19
  9. package/mcp/menubar/s4l_menubar.py +179 -56
  10. package/mcp/menubar/s4l_state.py +41 -1
  11. package/mcp/package.json +1 -1
  12. package/package.json +4 -2
  13. package/scripts/active_experiments.py +30 -6
  14. package/scripts/autopilot_stall_watch.py +185 -63
  15. package/scripts/cdp_ready_check.py +94 -0
  16. package/scripts/engage_twitter_helper.py +83 -17
  17. package/scripts/engagement_styles.py +221 -26
  18. package/scripts/enrich_reply_parents.py +68 -22
  19. package/scripts/feedback_digest.py +24 -1
  20. package/scripts/generate_daily_human_style.py +31 -7
  21. package/scripts/invent_styles.py +103 -26
  22. package/scripts/linkedin_cadence.py +234 -0
  23. package/scripts/linkedin_killswitch.py +43 -0
  24. package/scripts/memory_snapshot.py +57 -0
  25. package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
  26. package/scripts/recent_self_posts.py +6 -0
  27. package/scripts/reddit_browser.py +57 -3
  28. package/scripts/s4l_box_update.sh +20 -1
  29. package/scripts/s4l_mode.py +3 -4
  30. package/scripts/scan_x_profile.py +25 -7
  31. package/scripts/schedule_state.py +23 -0
  32. package/scripts/scheduled_task_selfheal.py +213 -16
  33. package/scripts/setup_twitter_auth.py +6 -1
  34. package/scripts/snapshot.py +179 -61
  35. package/scripts/stall_guard.py +70 -0
  36. package/scripts/test_scheduled_task_selfheal.py +121 -0
  37. package/scripts/top_performers.py +34 -0
  38. package/scripts/twitter_browser.py +146 -8
  39. package/scripts/twitter_post_plan.py +10 -1
  40. package/scripts/twitter_scan.py +26 -0
  41. package/scripts/watchdog_hung_runs.py +117 -3
  42. package/skill/amplitude-24h-signups.sh +1 -1
  43. package/skill/audit-dm-staleness.sh +1 -1
  44. package/skill/audit-linkedin.sh +1 -1
  45. package/skill/audit-reddit-resurrect.sh +1 -1
  46. package/skill/audit.sh +5 -5
  47. package/skill/check-web-chats.sh +1 -1
  48. package/skill/dm-outreach-linkedin.sh +4 -4
  49. package/skill/dm-outreach-reddit.sh +1 -1
  50. package/skill/dm-outreach-twitter.sh +1 -1
  51. package/skill/engage-dm-replies.sh +16 -3
  52. package/skill/engage-linkedin.sh +2 -2
  53. package/skill/engage-moltbook.sh +1 -1
  54. package/skill/engage-twitter.sh +4 -4
  55. package/skill/github-engage.sh +1 -1
  56. package/skill/lib/browser-launch.sh +54 -0
  57. package/skill/lib/linkedin-backend.sh +22 -15
  58. package/skill/lib/reddit-backend.sh +17 -11
  59. package/skill/lib/twitter-backend.sh +137 -16
  60. package/skill/link-edit-github.sh +1 -1
  61. package/skill/link-edit-moltbook.sh +1 -1
  62. package/skill/link-edit-reddit.sh +1 -1
  63. package/skill/linkedin-cadence.sh +20 -0
  64. package/skill/linkedin-presence.sh +1 -1
  65. package/skill/lock.sh +30 -6
  66. package/skill/precompute-stats.sh +1 -1
  67. package/skill/refresh-instagram-tokens.sh +1 -1
  68. package/skill/refresh-twitter-following.sh +1 -1
  69. package/skill/run-generate-daily-style.sh +1 -1
  70. package/skill/run-instagram-daily.sh +1 -1
  71. package/skill/run-instagram-render.sh +1 -1
  72. package/skill/run-linkedin-launchd.sh +1 -1
  73. package/skill/run-linkedin-unipile.sh +1 -1
  74. package/skill/run-linkedin.sh +1 -1
  75. package/skill/run-reddit-search.sh +2 -2
  76. package/skill/run-reddit-threads.sh +1 -1
  77. package/skill/run-twitter-cycle.sh +190 -55
  78. package/skill/scan-instagram-replies.sh +1 -1
  79. package/skill/sentry-digest.sh +1 -1
  80. package/skill/stats-instagram.sh +1 -1
  81. package/skill/stats-linkedin.sh +2 -2
  82. package/skill/stats.sh +1 -1
  83. package/skill/sweep-link-clicks.sh +1 -1
  84. package/scripts/_lock_preempt_test.py +0 -60
  85. package/scripts/saps_activity.py +0 -18
  86. package/scripts/saps_mode.py +0 -18
@@ -0,0 +1,118 @@
1
+ diff --git a/src/browser_harness/admin.py b/src/browser_harness/admin.py
2
+ index c72a8fb..64addee 100644
3
+ --- a/src/browser_harness/admin.py
4
+ +++ b/src/browser_harness/admin.py
5
+ @@ -5,6 +5,7 @@ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ +import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+
13
+ @@ -445,8 +446,29 @@ def _stop_cloud_browser(browser_id):
14
+ pass
15
+
16
+
17
+ +_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
18
+ +
19
+ +
20
+ +def cdp_urlopen(url, timeout=15):
21
+ + """urlopen for CDP endpoints: never send loopback traffic through a proxy.
22
+ +
23
+ + urllib's default opener honors proxy env vars AND macOS system proxy
24
+ + settings (scutil --proxy). On a box with a system-wide proxy (e.g. a local
25
+ + gost/ISP forwarder) every 127.0.0.1 CDP probe gets routed to the proxy and
26
+ + comes back 403 Forbidden — Chrome then looks unreachable/"wedged" while
27
+ + curl (env proxies only) and Playwright (own websocket stack) work fine.
28
+ + Loopback must always connect direct; non-loopback CDP URLs keep the
29
+ + default proxy behavior (a remote/cloud CDP endpoint may legitimately need
30
+ + the proxy for reachability).
31
+ + """
32
+ + host = (urllib.parse.urlsplit(url).hostname or "").lower()
33
+ + if host in ("127.0.0.1", "localhost", "::1"):
34
+ + return _NO_PROXY_OPENER.open(url, timeout=timeout)
35
+ + return urllib.request.urlopen(url, timeout=timeout)
36
+ +
37
+ +
38
+ def _cdp_ws_from_url(cdp_url):
39
+ - return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
40
+ + return json.loads(cdp_urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
41
+
42
+
43
+ def _has_local_gui():
44
+ diff --git a/src/browser_harness/daemon.py b/src/browser_harness/daemon.py
45
+ index 0f0f255..de51934 100644
46
+ --- a/src/browser_harness/daemon.py
47
+ +++ b/src/browser_harness/daemon.py
48
+ @@ -5,8 +5,22 @@ from collections import deque
49
+ from pathlib import Path
50
+
51
+ from . import _ipc as ipc
52
+ +from .admin import cdp_urlopen
53
+ from cdp_use.client import CDPClient
54
+
55
+ +# s4l_no_proxy_ws: the daemon's CDP traffic is loopback; never let ANY client
56
+ +# in this process route it through a proxy. websockets>=14 autodetects proxies
57
+ +# at dial time via urllib.request.proxy_bypass()/getproxies() (which on macOS
58
+ +# include SYSTEM proxy settings from scutil --proxy), so a box-wide forwarder
59
+ +# turns the CDP WS dial into a proxied CONNECT that fails ("proxy rejected
60
+ +# connection: HTTP 503") — same failure class as the urllib 403 that
61
+ +# cdp_urlopen fixes for the HTTP lookup. Neutralize resolution process-wide:
62
+ +# the daemon's only non-loopback traffic (browser-use cloud API) works direct.
63
+ +# Both lookups happen at call time via module attributes, so this holds
64
+ +# regardless of import order.
65
+ +urllib.request.getproxies = lambda: {} # type: ignore[assignment]
66
+ +urllib.request.proxy_bypass = lambda host: 1 # type: ignore[assignment]
67
+ +
68
+
69
+ def _load_env():
70
+ repo_root = Path(__file__).resolve().parents[2]
71
+ @@ -113,7 +127,7 @@ def get_ws_url():
72
+ base_url = url.rstrip("/")
73
+ while time.time() < deadline:
74
+ try:
75
+ - return json.loads(urllib.request.urlopen(f"{base_url}/json/version", timeout=5).read())["webSocketDebuggerUrl"]
76
+ + return json.loads(cdp_urlopen(f"{base_url}/json/version", timeout=5).read())["webSocketDebuggerUrl"]
77
+ except urllib.error.HTTPError as e:
78
+ last_err = e
79
+ if e.code == 404 and (ws := _ws_from_devtools_active_port(url)):
80
+ @@ -139,7 +153,7 @@ def get_ws_url():
81
+ deadline = time.time() + 30
82
+ while time.time() < deadline:
83
+ try:
84
+ - return json.loads(urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"]
85
+ + return json.loads(cdp_urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1).read())["webSocketDebuggerUrl"]
86
+ except urllib.error.HTTPError as e:
87
+ # Chrome 147+ disables /json/* HTTP discovery on the default user-data-dir;
88
+ # the ws path Chrome wrote to DevToolsActivePort still works.
89
+ @@ -153,7 +167,7 @@ def get_ws_url():
90
+ )
91
+ for probe_port in (9222, 9223):
92
+ try:
93
+ - with urllib.request.urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r:
94
+ + with cdp_urlopen(f"http://127.0.0.1:{probe_port}/json/version", timeout=1) as r:
95
+ return json.loads(r.read())["webSocketDebuggerUrl"]
96
+ except (OSError, KeyError, ValueError):
97
+ continue
98
+ diff --git a/src/browser_harness/run.py b/src/browser_harness/run.py
99
+ index 8ab1f0f..cbd3835 100644
100
+ --- a/src/browser_harness/run.py
101
+ +++ b/src/browser_harness/run.py
102
+ @@ -9,6 +9,7 @@ if hasattr(sys.stdout, "reconfigure"):
103
+
104
+ from .admin import (
105
+ _version,
106
+ + cdp_urlopen,
107
+ NAME,
108
+ daemon_alive,
109
+ ensure_daemon,
110
+ @@ -59,7 +60,7 @@ USAGE = """Usage:
111
+ def _local_chrome_listening():
112
+ for port in (9222, 9223):
113
+ try:
114
+ - urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.3).close()
115
+ + cdp_urlopen(f"http://127.0.0.1:{port}/json/version", timeout=0.3).close()
116
+ return True
117
+ except OSError: pass
118
+ return False
@@ -95,6 +95,12 @@ def build_block(platform, limit):
95
95
  "order_by": "posted_at",
96
96
  "order_dir": "desc",
97
97
  "limit": str(limit),
98
+ # Scope to THIS install's own posts (server filters on the
99
+ # authenticated X-Installation identity). Without this the
100
+ # default read returns the whole fleet's posts and the block
101
+ # would claim another account's replies as "yours"
102
+ # (found 2026-07-10: 8 of 20 rows were another install's).
103
+ "own_install": "true",
98
104
  },
99
105
  )
100
106
  rows = ((resp or {}).get("data") or {}).get("posts") or []
@@ -347,7 +347,53 @@ def _refresh_browser_lock():
347
347
  _heartbeat_bash_lease()
348
348
 
349
349
 
350
+ # --- L1/L2 browser-hang guards (2026-07-13, mirrors twitter_browser.py) ------
351
+ # L1: default per-op/navigation timeouts so no Playwright call can hang forever.
352
+ # L2: liveness heartbeat on the shell reddit-browser lock dir; lock held +
353
+ # stale heartbeat = wedged holder, TERMed by watchdog_hung_runs.py in ~30 min
354
+ # instead of the multi-hour age cap. See twitter_browser.py for the incident
355
+ # writeup (2026-07-13 pid 1380 media-capture wedge during a network flap).
356
+ BROWSER_OP_TIMEOUT_MS = 30_000
357
+ BROWSER_NAV_TIMEOUT_MS = 60_000
358
+ _BROWSER_LOCK_HEARTBEAT = "/tmp/social-autoposter-reddit-browser.lock/heartbeat"
359
+ _HB_THROTTLE_S = 5
360
+ _hb_last_touch = 0.0
361
+
362
+
363
+ def _touch_browser_heartbeat(*_args):
364
+ global _hb_last_touch
365
+ now = time.time()
366
+ if now - _hb_last_touch < _HB_THROTTLE_S:
367
+ return
368
+ _hb_last_touch = now
369
+ try:
370
+ if os.path.isdir(os.path.dirname(_BROWSER_LOCK_HEARTBEAT)):
371
+ with open(_BROWSER_LOCK_HEARTBEAT, "w") as f:
372
+ f.write(str(int(now)))
373
+ except Exception:
374
+ pass
375
+
376
+
350
377
  def get_browser_and_page(playwright):
378
+ """Instrumented entry point: attach via _get_browser_and_page_raw, then
379
+ (L1) set default per-op/navigation timeouts so no page call can hang
380
+ forever, and (L2) wire the browser-lock liveness heartbeat. All new code
381
+ should use THIS, never the raw variant."""
382
+ browser, page, is_cdp = _get_browser_and_page_raw(playwright)
383
+ try:
384
+ page.set_default_timeout(BROWSER_OP_TIMEOUT_MS)
385
+ page.set_default_navigation_timeout(BROWSER_NAV_TIMEOUT_MS)
386
+ except Exception:
387
+ pass
388
+ _touch_browser_heartbeat()
389
+ try:
390
+ page.on("request", _touch_browser_heartbeat)
391
+ except Exception:
392
+ pass
393
+ return browser, page, is_cdp
394
+
395
+
396
+ def _get_browser_and_page_raw(playwright):
351
397
  """Get a logged-in Reddit page, preferring CDP-attach over launch_persistent_context.
352
398
 
353
399
  Two paths:
@@ -1704,7 +1750,7 @@ def _log_dm_outbound(chat_url, content, dm_id=None, minted_codes=None):
1704
1750
  return False
1705
1751
 
1706
1752
 
1707
- def send_dm(chat_url, message, dm_id=None):
1753
+ def send_dm(chat_url, message, dm_id=None, apply_campaigns=True):
1708
1754
  """Send a message in a Reddit chat or PM thread.
1709
1755
 
1710
1756
  For chat URLs (reddit.com/chat/...), navigates to the chat room and
@@ -1746,8 +1792,12 @@ def send_dm(chat_url, message, dm_id=None):
1746
1792
  minted_link_codes = wrap_res.get("minted_codes", [])
1747
1793
 
1748
1794
  # Tool-level campaign suffix injection (guaranteed delivery of literal text).
1795
+ # apply_campaigns=False (via S4L_SKIP_CAMPAIGN_SUFFIX on the send-dm CLI)
1796
+ # opts this DM out — set ONLY for human-drafted escalation replies
1797
+ # (engage-dm-replies Phase 0); see twitter_browser.py send_dm for the
1798
+ # incident rationale. The autopilot never sets it.
1749
1799
  applied_campaigns = []
1750
- for cid, suffix, sample_rate in _load_active_reddit_campaigns_for_dm():
1800
+ for cid, suffix, sample_rate in (_load_active_reddit_campaigns_for_dm() if apply_campaigns else []):
1751
1801
  if random.random() < sample_rate:
1752
1802
  message = message + suffix
1753
1803
  applied_campaigns.append(cid)
@@ -2461,7 +2511,11 @@ def main():
2461
2511
  dm_id_arg = int(sys.argv[4])
2462
2512
  except ValueError:
2463
2513
  print(f"[send-dm] ignoring non-int dm_id arg: {sys.argv[4]!r}", file=sys.stderr)
2464
- result = send_dm(sys.argv[2], sys.argv[3], dm_id=dm_id_arg)
2514
+ # S4L_SKIP_CAMPAIGN_SUFFIX=1 opts this DM out of campaign suffixes.
2515
+ # Set ONLY by engage-dm-replies Phase 0 when delivering a human-drafted
2516
+ # escalation reply — see send_dm's apply_campaigns comment.
2517
+ _skip_camp = os.environ.get("S4L_SKIP_CAMPAIGN_SUFFIX", "").strip().lower() in ("1", "true", "yes")
2518
+ result = send_dm(sys.argv[2], sys.argv[3], dm_id=dm_id_arg, apply_campaigns=not _skip_camp)
2465
2519
  print(json.dumps(result, indent=2))
2466
2520
 
2467
2521
  elif cmd == "compose-dm":
@@ -61,7 +61,7 @@ esac
61
61
  # four space-separated tokens: "<channel> <tag> <version> <mcpb_url>".
62
62
  STATE_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}"
63
63
  RESOLVED="$(S4L_STATE_DIR="$STATE_DIR" "$PY" - <<'PYEOF' 2>/dev/null || true
64
- import json, os, re, subprocess
64
+ import json, os, re, subprocess, time
65
65
 
66
66
  state = os.environ.get("S4L_STATE_DIR") or os.path.join(os.path.expanduser("~"), ".social-autoposter-mcp")
67
67
  try:
@@ -113,6 +113,25 @@ if best is None:
113
113
  print("unresolved")
114
114
  raise SystemExit(0)
115
115
  tag = best[1]
116
+ # Deposit the freshly resolved release into the SHARED cross-process cache
117
+ # (<state dir>/latest-release.json) that version.ts and snapshot.py read, so
118
+ # this probe also feeds the menu bar / MCP for the next TTL window instead of
119
+ # them re-spending quota. etag stays null (this resolver does not capture one);
120
+ # the next conditional probe simply pays one 200 and re-seeds it. Best effort:
121
+ # a failed write never blocks the update itself. NOTE: staging resolves here
122
+ # from the same LIST endpoint the other surfaces use, but stable picks newest
123
+ # non-prerelease from the LIST while they use releases/latest — same result,
124
+ # so the cache stays consistent either way.
125
+ try:
126
+ os.makedirs(state, exist_ok=True)
127
+ _p = os.path.join(state, "latest-release.json")
128
+ _tmp = "%s.tmp.%d" % (_p, os.getpid())
129
+ with open(_tmp, "w") as _f:
130
+ json.dump({"at": time.time(), "channel": channel,
131
+ "version": tag.lstrip("v"), "tag": tag, "etag": None}, _f)
132
+ os.replace(_tmp, _p)
133
+ except Exception:
134
+ pass
116
135
  print("%s %s %s %s" % (channel, tag, tag.lstrip("v"), TAG_DL % (REPO, tag)))
117
136
  PYEOF
118
137
  )"
@@ -70,9 +70,8 @@ toggle): this CLI and mode.json are the only way to flip it.
70
70
  `env` additionally exports S4L_CYCLE_LANE=<lane> for both lanes so the wrapper
71
71
  knows which lane this cycle is without re-deriving it.
72
72
 
73
- Before the 2026-07-06 SAPS->S4L rename this file lived at scripts/saps_mode.py;
74
- a thin shim keeps that old path alive for the chflags-locked
75
- run-twitter-cycle.sh. Do not reference the old name in new code.
73
+ The pre-2026-07-06 helper filename is retired. Current code must import or
74
+ invoke this module directly.
76
75
  """
77
76
 
78
77
  import json
@@ -101,7 +100,7 @@ DEFAULT_PERSONAL_BRAND_SHARE = 0.5
101
100
 
102
101
 
103
102
  def state_dir() -> Path:
104
- # Mirrors mcp/src/index.ts sapsStateDir() and menubar/s4l_state.py state_dir().
103
+ # Mirrors mcp/src/index.ts s4lStateDir() and menubar/s4l_state.py state_dir().
105
104
  return Path(
106
105
  os.environ.get("S4L_STATE_DIR")
107
106
  or (Path.home() / ".social-autoposter-mcp")
@@ -324,13 +324,31 @@ def _own_posted_ids() -> set:
324
324
  from http_api import api_get # noqa: PLC0415
325
325
  import re
326
326
  ids: set = set()
327
- resp = api_get("/api/v1/posts", {"platform": "twitter",
328
- "has_our_url": "true", "limit": 2000})
329
- rows = ((resp or {}).get("data") or {}).get("posts") or []
330
- for r in rows:
331
- m = re.search(r"/status/(\d+)", str(r.get("our_url") or ""))
332
- if m:
333
- ids.add(m.group(1))
327
+ # The route caps limit at 500, so walk the FULL posting history with a
328
+ # forward posted_at cursor (order_dir=asc + since>=). On prolific
329
+ # accounts a single newest-500 page misses older S4L posts that the
330
+ # profile scan still reaches (bit the operator account: 12.8k posted
331
+ # statuses, 2 of 5 picked exemplars were S4L's own). The since filter
332
+ # is inclusive, so overlap rows dedupe via the set; a page whose max
333
+ # posted_at equals the cursor would loop and breaks instead.
334
+ cursor = None
335
+ for _ in range(80): # hard cap 40k statuses
336
+ q = {"platform": "twitter", "has_our_url": "true", "limit": 500,
337
+ "order_by": "posted_at", "order_dir": "asc"}
338
+ if cursor:
339
+ q["since"] = cursor
340
+ resp = api_get("/api/v1/posts", q)
341
+ rows = ((resp or {}).get("data") or {}).get("posts") or []
342
+ for r in rows:
343
+ m = re.search(r"/status/(\d+)", str(r.get("our_url") or ""))
344
+ if m:
345
+ ids.add(m.group(1))
346
+ if len(rows) < 500:
347
+ break
348
+ page_max = max((r.get("posted_at") or "" for r in rows), default="")
349
+ if not page_max or page_max == cursor:
350
+ break
351
+ cursor = page_max
334
352
  return ids
335
353
  except Exception as e:
336
354
  print(f"[scan_x_profile] own-posts exclusion unavailable: {e}", file=sys.stderr)
@@ -94,6 +94,29 @@ def _registry_worker_recs(by_id):
94
94
  # false "not scheduled".
95
95
  FIRING_WINDOW = 420
96
96
 
97
+ # --- Stall thresholds (single source of truth) --------------------------------
98
+ # The longest a fully healthy drain can take end to end: a draft job can wait
99
+ # minutes for a worker slot (per-minute scheduler cadence, workers busy on other
100
+ # jobs) and the drafting turn itself runs multiple minutes. Observed healthy
101
+ # episodes reach 8-10 min (2026-07-13); alarm at 20 min for margin. Every stall
102
+ # detector derives from this ONE number so they can't drift apart again (99e695bf
103
+ # retuned the two per-state timers to 1200s but left the end-to-end DRAFT_STUCK
104
+ # check at 300s, which then false-alarmed on healthy 8-minute drains). The Node
105
+ # server can't import this module — mcp/src/index.ts::AUTOPILOT_STALL_MS must be
106
+ # kept in sync by hand.
107
+ HEALTHY_DRAIN_MAX_SECONDS = 1200
108
+
109
+ # A pending draft job unclaimed this long: no routine is draining the queue
110
+ # (an idle queue has no pending job at all, so a quiet pipeline never trips it).
111
+ STALL_SECONDS = HEALTHY_DRAIN_MAX_SECONDS
112
+ # A job claimed but still sitting in running/ this long: the worker picked it up
113
+ # and then wedged mid-run (the claude -p drafting child died / never spawned).
114
+ RUNNING_STALL_SECONDS = HEALTHY_DRAIN_MAX_SECONDS
115
+ # The producer's "drafting Nm" label alive this long: the end-to-end clock. This
116
+ # is the only timer that spans claim→die→requeue churn loops, which reset the two
117
+ # per-state timers above on every transition while nothing ever completes.
118
+ DRAFT_STUCK_SECONDS = HEALTHY_DRAIN_MAX_SECONDS
119
+
97
120
  # Grace for a JUST-scheduled task that hasn't fired yet. When the user runs
98
121
  # "Set up draft schedule", create_scheduled_task registers both worker tasks
99
122
  # (cron "* * * * *", enabled) but lastRunAt is null until the host fires them the
@@ -29,12 +29,18 @@ Run as a script -> heals, then prints {"ok": true, ...} as JSON.
29
29
  from __future__ import annotations
30
30
 
31
31
  import glob
32
+ import ctypes
33
+ import ctypes.util
34
+ import hashlib
32
35
  import json
33
36
  import os
34
37
  import shutil
38
+ import sqlite3
39
+ import subprocess
35
40
  import sys
36
41
  import tempfile
37
42
  import time
43
+ import uuid as uuidlib
38
44
 
39
45
  import schedule_state # noqa: E402 (lives next to this file in scripts/)
40
46
 
@@ -53,6 +59,180 @@ SCHED_REGISTRY_GLOB = os.path.join(
53
59
  "claude-code-sessions", "*", "*", "scheduled-tasks.json",
54
60
  )
55
61
 
62
+ CLAUDE_COOKIE_HOST = ".claude.ai"
63
+ CLAUDE_SAFE_STORAGE_SERVICE = "Claude Safe Storage"
64
+
65
+
66
+ def _normalized_uuid(value) -> str | None:
67
+ """Return a canonical UUID string, or None for anything path-unsafe."""
68
+ if not isinstance(value, str):
69
+ return None
70
+ try:
71
+ return str(uuidlib.UUID(value.strip()))
72
+ except (ValueError, AttributeError):
73
+ return None
74
+
75
+
76
+ def _last_active_org_cookie_row(root: str):
77
+ """Read only the lastActiveOrg row from this Claude profile's cookie DB.
78
+
79
+ Opening through SQLite (rather than copying/parsing the file ourselves)
80
+ also lets SQLite include a live WAL when Claude is still running. Cookie
81
+ values never leave this helper except as the one selected row.
82
+ """
83
+ db = os.path.join(root, "Cookies")
84
+ if not os.path.isfile(db):
85
+ return None
86
+ conn = None
87
+ try:
88
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1)
89
+ return conn.execute(
90
+ """
91
+ SELECT host_key, value, encrypted_value
92
+ FROM cookies
93
+ WHERE name = 'lastActiveOrg'
94
+ AND (host_key = '.claude.ai' OR host_key = 'claude.ai')
95
+ ORDER BY last_access_utc DESC
96
+ LIMIT 1
97
+ """
98
+ ).fetchone()
99
+ except Exception:
100
+ return None
101
+ finally:
102
+ if conn is not None:
103
+ try:
104
+ conn.close()
105
+ except Exception:
106
+ pass
107
+
108
+
109
+ def _has_last_active_org_cookie(root: str) -> bool:
110
+ """Cheap capability probe that never reads Keychain or shows a prompt."""
111
+ row = _last_active_org_cookie_row(root)
112
+ if not row:
113
+ return False
114
+ _host, value, encrypted = row
115
+ if _normalized_uuid(value):
116
+ return True
117
+ return isinstance(encrypted, bytes) and encrypted.startswith(b"v10")
118
+
119
+
120
+ def _claude_safe_storage_password() -> bytes | None:
121
+ """Read Electron's per-user cookie key from macOS Keychain.
122
+
123
+ There is deliberately no password field in S4L. macOS owns authorization:
124
+ the read commonly succeeds silently, but it may show its standard Keychain
125
+ dialog for the logged-in user. A denial, locked keychain, missing item, or
126
+ unanswered dialog simply makes this best-effort resolver return None.
127
+ """
128
+ if sys.platform != "darwin":
129
+ return None
130
+ try:
131
+ result = subprocess.run(
132
+ [
133
+ "/usr/bin/security",
134
+ "find-generic-password",
135
+ "-w",
136
+ "-s",
137
+ CLAUDE_SAFE_STORAGE_SERVICE,
138
+ ],
139
+ stdin=subprocess.DEVNULL,
140
+ stdout=subprocess.PIPE,
141
+ stderr=subprocess.DEVNULL,
142
+ timeout=20,
143
+ check=False,
144
+ )
145
+ if result.returncode != 0 or not result.stdout:
146
+ return None
147
+ password = result.stdout
148
+ if password.endswith(b"\r\n"):
149
+ password = password[:-2]
150
+ elif password.endswith(b"\n"):
151
+ password = password[:-1]
152
+ return password or None
153
+ except Exception:
154
+ return None
155
+
156
+
157
+ def _aes_128_cbc_decrypt(ciphertext: bytes, key: bytes, iv: bytes) -> bytes | None:
158
+ """AES-CBC/PKCS7 via macOS CommonCrypto, available through libSystem.
159
+
160
+ Keeping this in-process avoids passing the derived cookie key on an
161
+ `openssl -K ...` command line, where another local process could see it.
162
+ """
163
+ if sys.platform != "darwin" or not ciphertext:
164
+ return None
165
+ try:
166
+ system_lib = ctypes.util.find_library("System") or "/usr/lib/libSystem.dylib"
167
+ common_crypto = ctypes.CDLL(system_lib)
168
+ cc_crypt = common_crypto.CCCrypt
169
+ cc_crypt.argtypes = [
170
+ ctypes.c_uint32, # operation
171
+ ctypes.c_uint32, # algorithm
172
+ ctypes.c_uint32, # options
173
+ ctypes.c_void_p, ctypes.c_size_t, # key, key length
174
+ ctypes.c_void_p, # IV
175
+ ctypes.c_void_p, ctypes.c_size_t, # input, input length
176
+ ctypes.c_void_p, ctypes.c_size_t, # output, output capacity
177
+ ctypes.POINTER(ctypes.c_size_t), # bytes written
178
+ ]
179
+ cc_crypt.restype = ctypes.c_int32
180
+
181
+ key_buf = ctypes.create_string_buffer(key, len(key))
182
+ iv_buf = ctypes.create_string_buffer(iv, len(iv))
183
+ input_buf = ctypes.create_string_buffer(ciphertext, len(ciphertext))
184
+ output_buf = ctypes.create_string_buffer(len(ciphertext) + 16)
185
+ output_len = ctypes.c_size_t()
186
+ status = cc_crypt(
187
+ 1, # kCCDecrypt
188
+ 0, # kCCAlgorithmAES
189
+ 1, # kCCOptionPKCS7Padding
190
+ key_buf, len(key),
191
+ iv_buf,
192
+ input_buf, len(ciphertext),
193
+ output_buf, len(output_buf),
194
+ ctypes.byref(output_len),
195
+ )
196
+ if status != 0:
197
+ return None
198
+ return output_buf.raw[:output_len.value]
199
+ except Exception:
200
+ return None
201
+
202
+
203
+ def _active_org_uuid_from_cookie(root: str) -> str | None:
204
+ """Resolve the active org without any claude-code-sessions directory.
205
+
206
+ Chromium's macOS v10 cookie format uses a PBKDF2-derived AES key from the
207
+ app's Safe Storage item. Newer databases bind plaintext to the cookie host
208
+ by prepending SHA-256(host_key); older ones omit that prefix, so accept
209
+ both formats but only return a syntactically valid UUID.
210
+ """
211
+ row = _last_active_org_cookie_row(root)
212
+ if not row:
213
+ return None
214
+ host, value, encrypted = row
215
+ plain_uuid = _normalized_uuid(value)
216
+ if plain_uuid:
217
+ return plain_uuid
218
+ if not isinstance(encrypted, bytes) or not encrypted.startswith(b"v10"):
219
+ return None
220
+
221
+ password = _claude_safe_storage_password()
222
+ if not password:
223
+ return None
224
+ key = hashlib.pbkdf2_hmac("sha1", password, b"saltysalt", 1003, 16)
225
+ clear = _aes_128_cbc_decrypt(encrypted[3:], key, b" " * 16)
226
+ if clear is None:
227
+ return None
228
+ host_digest = hashlib.sha256(str(host).encode("utf-8")).digest()
229
+ if clear.startswith(host_digest):
230
+ clear = clear[len(host_digest):]
231
+ try:
232
+ return _normalized_uuid(clear.decode("utf-8"))
233
+ except UnicodeDecodeError:
234
+ return None
235
+
56
236
 
57
237
  def _ensure_worker_skill_md() -> bool:
58
238
  """Make sure ~/.claude/scheduled-tasks/s4l-worker/SKILL.md exists before we
@@ -110,9 +290,13 @@ def heal() -> dict:
110
290
  tree held zero registry files. Resolves the active account via
111
291
  schedule_state.py's config.json lookup (lastKnownAccountUuid,
112
292
  verified correct against real installs 2026-07-08) and writes a
113
- fresh worker entry into its most-recently-touched EXISTING session
114
- directory (never fabricates a new directory). Same user-intent and
115
- worker_skill_ok guards as fix 4.
293
+ fresh worker entry into its most-recently-touched existing session
294
+ directory. If the account has no directory yet, decrypts Claude
295
+ Desktop's .claude.ai/lastActiveOrg cookie with the current user's
296
+ macOS `Claude Safe Storage` Keychain item and creates the exact
297
+ <account>/<organization> pair. Keychain denial/missing-cookie cases
298
+ fail closed and retain the live-host-tool fallback. Same user-intent
299
+ and worker_skill_ok guards as fix 4.
116
300
  Best-effort: never raises. Returns a small summary dict for logging."""
117
301
  summary = {"ok": True, "edited": [], "created": [], "error": None}
118
302
  try:
@@ -218,12 +402,25 @@ def heal() -> dict:
218
402
  p for p in glob.glob(os.path.join(account_dir, "*"))
219
403
  if os.path.isdir(p)
220
404
  ]
221
- if not session_dirs:
222
- continue # Desktop has never created a session for this
223
- # account on this box -- nowhere safe to write
224
- # Most-recently-touched session dir is the best available
225
- # guess for "the one Desktop is actively using".
226
- target_dir = max(session_dirs, key=lambda p: os.path.getmtime(p))
405
+ if session_dirs:
406
+ # Most-recently-touched session dir is the best available
407
+ # choice when Desktop has already materialized one.
408
+ target_dir = max(session_dirs, key=lambda p: os.path.getmtime(p))
409
+ else:
410
+ # A login creates lastActiveOrg before agent mode creates
411
+ # claude-code-sessions. It is therefore the missing
412
+ # server-assigned half of the exact account/org path.
413
+ org_uuid = _active_org_uuid_from_cookie(root)
414
+ if not org_uuid:
415
+ continue
416
+ target_dir = os.path.join(account_dir, org_uuid)
417
+ try:
418
+ # Private identity-scoped state. Do not chmod a path
419
+ # Desktop already made; mode applies only when new.
420
+ os.makedirs(account_dir, mode=0o700, exist_ok=True)
421
+ os.makedirs(target_dir, mode=0o700, exist_ok=True)
422
+ except Exception:
423
+ continue
227
424
  target_file = os.path.join(target_dir, "scheduled-tasks.json")
228
425
  new_entry = {
229
426
  "id": WORKER_TASK_ID,
@@ -268,12 +465,12 @@ def heal() -> dict:
268
465
 
269
466
  def can_create_for_active_account() -> bool:
270
467
  """Read-only: would fix 5 (see heal()) actually be able to create a fresh
271
- registration right now? True only if the active account (resolved the same
272
- way heal() does, via schedule_state's config.json lookup) has at least one
273
- EXISTING session directory to write into — fix 5 never fabricates one.
274
- Used by callers (the menu bar) to decide whether to offer an automatic
275
- "restart to finish setup" action or fall back to the manual re-arm prompt,
276
- BEFORE committing to a restart that would turn out to fix nothing."""
468
+ registration right now? True if the active account has either an existing
469
+ session directory or a plausible lastActiveOrg cookie. This passive menu/
470
+ status probe deliberately does NOT read Keychain (which could show an
471
+ authorization dialog); heal() performs and validates decryption only after
472
+ the user chooses the restart action. Any denial then fails closed and the
473
+ post-restart verification points the user to the manual re-arm fallback."""
277
474
  try:
278
475
  for cfg in schedule_state._config_json_paths():
279
476
  root = os.path.dirname(cfg)
@@ -285,7 +482,7 @@ def can_create_for_active_account() -> bool:
285
482
  p for p in glob.glob(os.path.join(account_dir, "*"))
286
483
  if os.path.isdir(p)
287
484
  ]
288
- if session_dirs:
485
+ if session_dirs or _has_last_active_org_cookie(root):
289
486
  return True
290
487
  except Exception:
291
488
  pass
@@ -199,7 +199,12 @@ def _launch_chrome() -> bool:
199
199
  cmd += ["--window-position=80,80", "--window-size=1100,900"]
200
200
  cmd.append("about:blank")
201
201
  PROFILE_DIR.mkdir(parents=True, exist_ok=True)
202
- proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
202
+ # start_new_session: Chrome must not inherit this process's group. When the
203
+ # caller is a transient launchd job, launchd SIGKILLs the job's whole
204
+ # process group on exit, reaping Chrome with it; the next lane's relaunch
205
+ # then steals the user's focus (2026-07-12, same fix as *-backend.sh).
206
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
207
+ start_new_session=True)
203
208
  try:
204
209
  PID_FILE.write_text(str(proc.pid))
205
210
  except OSError: