@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.
- package/README.md +134 -101
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +24 -5
- package/mcp/dist/runtime.js +110 -0
- package/mcp/dist/version.js +163 -54
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +55 -19
- package/mcp/menubar/s4l_menubar.py +179 -56
- package/mcp/menubar/s4l_state.py +41 -1
- package/mcp/package.json +1 -1
- package/package.json +4 -2
- package/scripts/active_experiments.py +30 -6
- package/scripts/autopilot_stall_watch.py +185 -63
- package/scripts/cdp_ready_check.py +94 -0
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +221 -26
- package/scripts/enrich_reply_parents.py +68 -22
- package/scripts/feedback_digest.py +24 -1
- package/scripts/generate_daily_human_style.py +31 -7
- package/scripts/invent_styles.py +103 -26
- package/scripts/linkedin_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +43 -0
- package/scripts/memory_snapshot.py +57 -0
- package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/reddit_browser.py +57 -3
- package/scripts/s4l_box_update.sh +20 -1
- package/scripts/s4l_mode.py +3 -4
- package/scripts/scan_x_profile.py +25 -7
- package/scripts/schedule_state.py +23 -0
- package/scripts/scheduled_task_selfheal.py +213 -16
- package/scripts/setup_twitter_auth.py +6 -1
- package/scripts/snapshot.py +179 -61
- package/scripts/stall_guard.py +70 -0
- package/scripts/test_scheduled_task_selfheal.py +121 -0
- package/scripts/top_performers.py +34 -0
- package/scripts/twitter_browser.py +146 -8
- package/scripts/twitter_post_plan.py +10 -1
- package/scripts/twitter_scan.py +26 -0
- package/scripts/watchdog_hung_runs.py +117 -3
- package/skill/amplitude-24h-signups.sh +1 -1
- package/skill/audit-dm-staleness.sh +1 -1
- package/skill/audit-linkedin.sh +1 -1
- package/skill/audit-reddit-resurrect.sh +1 -1
- package/skill/audit.sh +5 -5
- package/skill/check-web-chats.sh +1 -1
- package/skill/dm-outreach-linkedin.sh +4 -4
- package/skill/dm-outreach-reddit.sh +1 -1
- package/skill/dm-outreach-twitter.sh +1 -1
- package/skill/engage-dm-replies.sh +16 -3
- package/skill/engage-linkedin.sh +2 -2
- package/skill/engage-moltbook.sh +1 -1
- package/skill/engage-twitter.sh +4 -4
- package/skill/github-engage.sh +1 -1
- package/skill/lib/browser-launch.sh +54 -0
- package/skill/lib/linkedin-backend.sh +22 -15
- package/skill/lib/reddit-backend.sh +17 -11
- package/skill/lib/twitter-backend.sh +137 -16
- package/skill/link-edit-github.sh +1 -1
- package/skill/link-edit-moltbook.sh +1 -1
- package/skill/link-edit-reddit.sh +1 -1
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/linkedin-presence.sh +1 -1
- package/skill/lock.sh +30 -6
- package/skill/precompute-stats.sh +1 -1
- package/skill/refresh-instagram-tokens.sh +1 -1
- package/skill/refresh-twitter-following.sh +1 -1
- package/skill/run-generate-daily-style.sh +1 -1
- package/skill/run-instagram-daily.sh +1 -1
- package/skill/run-instagram-render.sh +1 -1
- package/skill/run-linkedin-launchd.sh +1 -1
- package/skill/run-linkedin-unipile.sh +1 -1
- package/skill/run-linkedin.sh +1 -1
- package/skill/run-reddit-search.sh +2 -2
- package/skill/run-reddit-threads.sh +1 -1
- package/skill/run-twitter-cycle.sh +190 -55
- package/skill/scan-instagram-replies.sh +1 -1
- package/skill/sentry-digest.sh +1 -1
- package/skill/stats-instagram.sh +1 -1
- package/skill/stats-linkedin.sh +2 -2
- package/skill/stats.sh +1 -1
- package/skill/sweep-link-clicks.sh +1 -1
- package/scripts/_lock_preempt_test.py +0 -60
- package/scripts/saps_activity.py +0 -18
- 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
|
-
|
|
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
|
)"
|
package/scripts/s4l_mode.py
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
114
|
-
directory
|
|
115
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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:
|