@m13v/s4l 1.7.6-rc.8 → 1.7.6
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 +1 -1
- package/bin/cli.js +33 -9
- package/mcp/dist/index.js +290 -177
- package/mcp/dist/panel.html +31 -17
- package/mcp/dist/setup.js +27 -7
- package/mcp/dist/telemetry.js +15 -6
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +10 -2
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +63 -4
- package/mcp/menubar/s4l_card_canvas.py +62 -11
- package/mcp/menubar/s4l_menubar.py +380 -95
- package/mcp/menubar/s4l_state.py +119 -41
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +58 -14
- package/scripts/amplitude_24h_signups.py +7 -4
- package/scripts/amplitude_signups.py +7 -4
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/claude_job.py +70 -10
- package/scripts/dm_conversation.py +15 -12
- package/scripts/draft_prompt_core.py +132 -64
- package/scripts/engage_github.py +11 -5
- package/scripts/engage_reddit.py +12 -5
- package/scripts/engagement_signal_feedback.py +1 -4
- package/scripts/engagement_styles.py +144 -105
- package/scripts/feedback_digest.py +54 -11
- package/scripts/find_threads.py +9 -7
- package/scripts/github_tools.py +23 -10
- package/scripts/learned_preferences.py +112 -78
- package/scripts/li_discovery.py +4 -1
- package/scripts/link_tail.py +1 -1
- package/scripts/live_status.py +151 -0
- package/scripts/merge_review_queue.py +15 -2
- package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
- package/scripts/pick_project.py +7 -4
- package/scripts/pick_thread_target.py +64 -13
- package/scripts/pick_twitter_thread_target.py +7 -4
- package/scripts/platform_strike_events.py +139 -7
- package/scripts/post_github.py +376 -35
- package/scripts/post_reddit.py +202 -192
- package/scripts/preflight.sh +23 -3
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/project_stats.py +7 -4
- package/scripts/reddit_ban_check.py +324 -0
- package/scripts/reddit_browser.py +56 -19
- package/scripts/reddit_chat_sync.py +10 -11
- package/scripts/reddit_tools.py +20 -4
- package/scripts/release-mcpb.sh +47 -7
- package/scripts/run_moltbook_cycle.py +9 -5
- package/scripts/s4l_ctl.sh +2 -2
- package/scripts/s4l_posting_mode.py +67 -6
- package/scripts/scan_dm_candidates.py +15 -9
- package/scripts/scan_github_replies.py +9 -7
- package/scripts/scan_moltbook_replies.py +7 -6
- package/scripts/scan_reddit_replies.py +11 -7
- package/scripts/scan_twitter_mentions_browser.py +7 -6
- package/scripts/scan_twitter_thread_followups.py +7 -6
- package/scripts/schedule_state.py +8 -4
- package/scripts/score_twitter_candidates.py +8 -3
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/snapshot.py +19 -0
- package/scripts/stats.py +12 -10
- package/scripts/strike_alert.py +165 -12
- package/scripts/sub_moderation_history.py +129 -0
- package/scripts/subreddit_bans_client.py +157 -0
- package/scripts/test_cross_language_parity.py +185 -0
- package/scripts/test_no_silent_fallbacks.py +84 -0
- package/scripts/test_percard_posting.py +3 -3
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/scripts/twitter_gen_links.py +3 -3
- package/scripts/twitter_post_plan.py +4 -4
- package/scripts/virality.py +151 -0
- package/setup/SKILL.md +1 -1
- package/skill/engage-dm-replies.sh +11 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-draft-and-publish.sh +16 -0
- package/skill/run-reddit-search-launchd.sh +1 -0
- package/skill/run-reddit-search.sh +10 -1
- package/skill/run-reddit-threads.sh +12 -0
- package/skill/run-twitter-cycle.sh +104 -88
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -90,22 +90,42 @@ def state_dir() -> str:
|
|
|
90
90
|
# to the MCP: this needs to work from BOTH the menu bar (s4l_menubar.py) and
|
|
91
91
|
# the Claude-independent dashboard_server.py, which share this process but
|
|
92
92
|
# are a different process from the MCP server, so there's no way to delegate
|
|
93
|
-
# to mcp/src/index.ts's pauseS4L/resumeS4L here.
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
93
|
+
# to mcp/src/index.ts's pauseS4L/resumeS4L here. The label SET is not
|
|
94
|
+
# duplicated though: index.ts's PAUSE_TARGETS registry is the single source of
|
|
95
|
+
# truth, mirrored to <state>/pause-targets.json at every MCP boot and
|
|
96
|
+
# pause/resume; pause_target_labels() reads that manifest, so a pipeline daemon
|
|
97
|
+
# added to the registry reaches this button without touching this file.
|
|
98
|
+
# PAUSE_TARGET_LABELS below is only the fallback for a box whose MCP has never
|
|
99
|
+
# written the manifest. What this resume deliberately does NOT do:
|
|
100
|
+
# mcp/src/index.ts's resumeS4L also re-runs the readiness-gated install logic
|
|
101
|
+
# (persona/project/X-verified checks, content-aware plist rewrite) via
|
|
102
|
+
# ensure*Installed() — that only matters for a box that was never fully set up
|
|
103
|
+
# before Pause, an edge case the next Claude boot / project_config call
|
|
104
|
+
# naturally heals once unpaused. This resume just reloads the exact plists
|
|
105
|
+
# Pause unloaded (Pause never deletes anything).
|
|
101
106
|
PAUSE_TARGET_LABELS = (
|
|
102
107
|
AUTOPILOT_LABEL,
|
|
108
|
+
"com.m13v.social-reddit-search",
|
|
103
109
|
"com.m13v.social-claude-reaper",
|
|
104
110
|
"com.m13v.social-autopilot-stall-watch",
|
|
105
111
|
"com.m13v.social-memory-snapshot",
|
|
106
112
|
)
|
|
107
113
|
|
|
108
114
|
|
|
115
|
+
def pause_target_labels() -> tuple:
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads((Path(state_dir()) / "pause-targets.json").read_text())
|
|
118
|
+
labels = tuple(
|
|
119
|
+
l for l in data.get("labels", [])
|
|
120
|
+
if isinstance(l, str) and l.startswith("com.m13v.")
|
|
121
|
+
)
|
|
122
|
+
if labels:
|
|
123
|
+
return labels
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return PAUSE_TARGET_LABELS
|
|
127
|
+
|
|
128
|
+
|
|
109
129
|
def pause_flag_path() -> str:
|
|
110
130
|
return os.path.join(state_dir(), "paused.flag")
|
|
111
131
|
|
|
@@ -125,7 +145,7 @@ def pause_s4l() -> dict:
|
|
|
125
145
|
return {"ok": False, "paused": is_paused(), "detail": f"could not write pause flag: {e}"}
|
|
126
146
|
uid = os.getuid()
|
|
127
147
|
results = []
|
|
128
|
-
for label in
|
|
148
|
+
for label in pause_target_labels():
|
|
129
149
|
try:
|
|
130
150
|
subprocess.run(
|
|
131
151
|
["launchctl", "bootout", f"gui/{uid}/{label}"],
|
|
@@ -147,7 +167,7 @@ def resume_s4l() -> dict:
|
|
|
147
167
|
uid = os.getuid()
|
|
148
168
|
la_dir = str(Path.home() / "Library" / "LaunchAgents")
|
|
149
169
|
results = []
|
|
150
|
-
for label in
|
|
170
|
+
for label in pause_target_labels():
|
|
151
171
|
plist = os.path.join(la_dir, f"{label}.plist")
|
|
152
172
|
if not os.path.exists(plist):
|
|
153
173
|
results.append(f"{label}: no plist, skip")
|
|
@@ -269,7 +289,7 @@ def autopilot_loaded() -> bool:
|
|
|
269
289
|
# A machine with any system-wide proxy configured (corporate VPN client,
|
|
270
290
|
# security software, a residential-IP proxy for platform fingerprinting,
|
|
271
291
|
# etc.) whose bypass list doesn't happen to include 127.0.0.1/localhost will
|
|
272
|
-
# have every loopback health check and
|
|
292
|
+
# have every loopback health check and approve_drafts call silently routed out
|
|
273
293
|
# through that proxy instead of hitting the MCP server directly — surfacing
|
|
274
294
|
# as an opaque connection error/403 with no indication the proxy is at
|
|
275
295
|
# fault. This is a local process talking to its own local server: it must
|
|
@@ -277,19 +297,39 @@ def autopilot_loaded() -> bool:
|
|
|
277
297
|
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
278
298
|
|
|
279
299
|
|
|
300
|
+
# /health is a trivial constant response, so any real latency is the OWNING MCP
|
|
301
|
+
# process being briefly CPU-starved or paged out under machine load (Nhat's box:
|
|
302
|
+
# ~8.6GB swap, 39% mem pressure, 582 procs), not the endpoint doing work. The old
|
|
303
|
+
# single 1.5s attempt flapped a perfectly-alive server to "unreachable" on one
|
|
304
|
+
# transient stall, and a false negative here strands approved drafts (see the
|
|
305
|
+
# "dead pointer" note in mcp/src/index.ts). So probe forgivingly: a longer
|
|
306
|
+
# deadline plus ONE retry. A genuinely-dead endpoint fails fast either way
|
|
307
|
+
# (ECONNREFUSED returns immediately, both attempts), so this only rescues live
|
|
308
|
+
# servers -- it never lets a real outage look healthy.
|
|
309
|
+
_HEALTH_TIMEOUT_S = 4.0
|
|
310
|
+
_HEALTH_ATTEMPTS = 2
|
|
311
|
+
_HEALTH_RETRY_BACKOFF_S = 0.15
|
|
312
|
+
|
|
313
|
+
|
|
280
314
|
def _endpoint_url():
|
|
281
315
|
ep = read_json("panel-endpoint.json")
|
|
282
316
|
url = (ep or {}).get("url")
|
|
283
317
|
if not url:
|
|
284
318
|
return None
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
319
|
+
last = None
|
|
320
|
+
for attempt in range(_HEALTH_ATTEMPTS):
|
|
321
|
+
try:
|
|
322
|
+
with _NO_PROXY_OPENER.open(url + "health", timeout=_HEALTH_TIMEOUT_S) as r:
|
|
323
|
+
return url if r.status == 200 else None
|
|
324
|
+
except Exception as e:
|
|
325
|
+
last = e
|
|
326
|
+
if attempt + 1 < _HEALTH_ATTEMPTS:
|
|
327
|
+
time.sleep(_HEALTH_RETRY_BACKOFF_S)
|
|
328
|
+
sys.stderr.write(
|
|
329
|
+
f"[s4l-state] loopback health check failed after {_HEALTH_ATTEMPTS} "
|
|
330
|
+
f"attempts: {type(last).__name__}: {last}\n"
|
|
331
|
+
)
|
|
332
|
+
sys.stderr.flush()
|
|
293
333
|
return None
|
|
294
334
|
|
|
295
335
|
|
|
@@ -514,7 +554,7 @@ def accessibility_trusted() -> bool:
|
|
|
514
554
|
# draft_cycle writes review-request.json when a fresh batch is ready; we read
|
|
515
555
|
# the linked plan file (the /tmp/twitter_cycle_plan_<batch>.json the pipeline
|
|
516
556
|
# produced), present the cards, then post the approved subset via the loopback
|
|
517
|
-
#
|
|
557
|
+
# approve_drafts tool. The chat-table review still works in parallel; both surfaces
|
|
518
558
|
# de-dup on the plan's per-candidate `posted` flag.
|
|
519
559
|
# How long an activity signal may go un-refreshed before the menu bar treats it
|
|
520
560
|
# as idle. This is the SELF-HEAL for a frozen spinner: a writer can set a label
|
|
@@ -565,7 +605,7 @@ def read_activity():
|
|
|
565
605
|
def write_activity(state: str, label: str):
|
|
566
606
|
"""Best-effort local activity update. The MCP server normally owns this file,
|
|
567
607
|
but the menu-bar posting queue knows the whole approved-card burst while the
|
|
568
|
-
server only sees one
|
|
608
|
+
server only sees one approve_drafts call at a time."""
|
|
569
609
|
try:
|
|
570
610
|
p = Path(state_dir()) / "activity.json"
|
|
571
611
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -707,6 +747,11 @@ def candidate_state(c):
|
|
|
707
747
|
"""Canonical lifecycle state of ONE review-queue candidate. Use this instead
|
|
708
748
|
of ad hoc flag checks — "not posted" is NOT "awaiting review".
|
|
709
749
|
|
|
750
|
+
LOCKSTEP: mcp/src/index.ts::candidateState() is the TS mirror of this exact
|
|
751
|
+
precedence; change one and you MUST change the other. Every past divergence
|
|
752
|
+
between a hand-rolled raw-flag filter and this function became an incident
|
|
753
|
+
(post_failed drain asymmetry 2026-07-17, get_stats lane blindness).
|
|
754
|
+
|
|
710
755
|
The review queue (review-queue.json) is an APPEND-FOREVER LEDGER: handled
|
|
711
756
|
candidates are never removed, they are flag-stamped in place, so most rows
|
|
712
757
|
in an old queue are retired. States, in precedence order:
|
|
@@ -903,7 +948,7 @@ def store_mark_post_failed(batch, n, candidate_id=None, error=None):
|
|
|
903
948
|
|
|
904
949
|
def store_pending_posts(batch="review-queue"):
|
|
905
950
|
"""Approved-but-unposted candidates, for the restart resume path. Skips
|
|
906
|
-
posted/terminal/failed rows; each entry carries everything
|
|
951
|
+
posted/terminal/failed rows; each entry carries everything approve_drafts
|
|
907
952
|
needs (n, final text, drop_link)."""
|
|
908
953
|
plan = read_plan(store_path())
|
|
909
954
|
out = []
|
|
@@ -926,7 +971,7 @@ def store_pending_posts(batch="review-queue"):
|
|
|
926
971
|
|
|
927
972
|
def store_reconcile_decisions(batch, decisions):
|
|
928
973
|
"""Re-stamp any of this session's decisions the store no longer shows. The
|
|
929
|
-
MCP server's
|
|
974
|
+
MCP server's approve_drafts does a whole-file read-modify-write while a batch
|
|
930
975
|
posts (minutes), so a decision stamped mid-drain can be clobbered by its
|
|
931
976
|
stale rewrite; the menubar calls this every timer tick with its in-memory
|
|
932
977
|
decision list so the store always converges back to what the user chose.
|
|
@@ -960,7 +1005,7 @@ def review_queue_posted_count():
|
|
|
960
1005
|
"""Posts that have LANDED in the review-queue plan — the durable, cross-process
|
|
961
1006
|
truth. Independent of the menu bar's in-memory burst queue (which dies on a
|
|
962
1007
|
restart) and of WHICH process is posting (the menu bar worker, the autopilot,
|
|
963
|
-
or a host agent draining via
|
|
1008
|
+
or a host agent draining via approve_drafts). Returns the posted count, or None
|
|
964
1009
|
when the plan can't be read. Drives the menu-bar posting indicator so progress
|
|
965
1010
|
stays visible regardless of how the drain is driven."""
|
|
966
1011
|
plan_path = None
|
|
@@ -997,13 +1042,17 @@ def review_drafts(plan, batch="review-queue"):
|
|
|
997
1042
|
continue
|
|
998
1043
|
out.append(
|
|
999
1044
|
{
|
|
1000
|
-
"n": i + 1, # 1-based, matches
|
|
1045
|
+
"n": i + 1, # 1-based, matches approve_drafts numbering
|
|
1001
1046
|
# Platform tag (reddit cards, 2026-07-14): absent/None means
|
|
1002
1047
|
# twitter (every plan written before the field shipped). The
|
|
1003
1048
|
# card renders the platform mark and profile link from this.
|
|
1004
1049
|
"platform": c.get("platform"),
|
|
1005
1050
|
"thread_author": c.get("thread_author"),
|
|
1006
1051
|
"thread_text": c.get("thread_text"),
|
|
1052
|
+
# Reddit selftext body (2026-07-17): the card renders the title
|
|
1053
|
+
# bold with this beneath it. Absent on twitter and on plans
|
|
1054
|
+
# written before merge_review_queue started stamping it.
|
|
1055
|
+
"thread_selftext": c.get("thread_selftext"),
|
|
1007
1056
|
"reply_text": c.get("reply_text") or "",
|
|
1008
1057
|
# English translations stamped at draft time (prep step) when
|
|
1009
1058
|
# language != en. Display-only: the card shows them so the
|
|
@@ -1253,11 +1302,16 @@ def write_review_layout(layout):
|
|
|
1253
1302
|
# Server-side per-install throttle for the twitter cycle's virality bar:
|
|
1254
1303
|
# high|medium|low|None (None = driver default). Source of truth is
|
|
1255
1304
|
# installations.posting_mode via /api/v1/installations/posting-mode; unlike
|
|
1256
|
-
# the lane flags there is NO local mode.json copy
|
|
1257
|
-
# remote adjustability (dashboard and menubar must
|
|
1305
|
+
# the lane flags there is NO local mode.json copy that RESOLVES the mode,
|
|
1306
|
+
# because the whole point is remote adjustability (dashboard and menubar must
|
|
1307
|
+
# agree with the server).
|
|
1258
1308
|
# Reads are cached so the menu rebuild tick never blocks on the network: a
|
|
1259
1309
|
# stale/missing cache kicks one background refresh and returns the last-known
|
|
1260
|
-
# value
|
|
1310
|
+
# value. The in-memory cache is seeded from a disk copy of the last server
|
|
1311
|
+
# answer (scripts/s4l_posting_mode.py cache file, 2026-07-17): without it,
|
|
1312
|
+
# every menubar restart (updates restart the menubar) displayed the default
|
|
1313
|
+
# "Steady" until the first refresh landed, which read as the user's chosen
|
|
1314
|
+
# mode having been reset. The seed never suppresses a refresh (at stays 0.0).
|
|
1261
1315
|
POSTING_MODE_TTL_SECS = 600
|
|
1262
1316
|
_posting_mode_cache = {"mode": None, "known": False, "at": 0.0}
|
|
1263
1317
|
_posting_mode_lock = threading.Lock()
|
|
@@ -1270,21 +1324,42 @@ def _scripts_on_path():
|
|
|
1270
1324
|
sys.path.insert(0, scripts)
|
|
1271
1325
|
|
|
1272
1326
|
|
|
1327
|
+
def _seed_posting_mode_from_disk():
|
|
1328
|
+
"""Seed the in-memory cache with the last server answer that any surface
|
|
1329
|
+
persisted (menubar, MCP tool, panel). at stays 0.0 so the first read still
|
|
1330
|
+
kicks a real refresh; the server answer overwrites the seed when it lands."""
|
|
1331
|
+
try:
|
|
1332
|
+
_scripts_on_path()
|
|
1333
|
+
from s4l_posting_mode import read_cached_mode
|
|
1334
|
+
|
|
1335
|
+
mode = read_cached_mode()
|
|
1336
|
+
if mode:
|
|
1337
|
+
with _posting_mode_lock:
|
|
1338
|
+
_posting_mode_cache["mode"] = mode
|
|
1339
|
+
except Exception:
|
|
1340
|
+
pass
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
_seed_posting_mode_from_disk()
|
|
1344
|
+
|
|
1345
|
+
|
|
1273
1346
|
def _refresh_posting_mode():
|
|
1274
1347
|
try:
|
|
1275
1348
|
_scripts_on_path()
|
|
1276
1349
|
from http_api import api_get
|
|
1350
|
+
from s4l_posting_mode import write_cached_mode
|
|
1277
1351
|
|
|
1278
1352
|
r = api_get("/api/v1/installations/posting-mode")
|
|
1279
1353
|
d = (r or {}).get("data") or {}
|
|
1280
1354
|
mode = d.get("mode")
|
|
1355
|
+
mode = mode if mode in ("high", "medium", "low") else None
|
|
1356
|
+
write_cached_mode(mode)
|
|
1281
1357
|
with _posting_mode_lock:
|
|
1282
|
-
_posting_mode_cache.update(
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
except Exception as e:
|
|
1358
|
+
_posting_mode_cache.update(mode=mode, known=True, at=time.time())
|
|
1359
|
+
except (Exception, SystemExit) as e:
|
|
1360
|
+
# http_api raises SystemExit (not Exception) on terminal failure;
|
|
1361
|
+
# catching only Exception let it kill this thread BEFORE the retry
|
|
1362
|
+
# throttle below, so offline boxes hot-looped refresh attempts.
|
|
1288
1363
|
sys.stderr.write(f"[s4l-state] posting-mode refresh failed: {e}\n")
|
|
1289
1364
|
with _posting_mode_lock:
|
|
1290
1365
|
# Throttle retries to the TTL window; keep last-known value.
|
|
@@ -1312,12 +1387,15 @@ def write_posting_mode(mode):
|
|
|
1312
1387
|
r = api_post("/api/v1/installations/posting-mode", {"mode": mode})
|
|
1313
1388
|
d = (r or {}).get("data") or {}
|
|
1314
1389
|
stored = d.get("mode")
|
|
1390
|
+
stored_valid = stored if stored in ("high", "medium", "low") else None
|
|
1391
|
+
try:
|
|
1392
|
+
from s4l_posting_mode import write_cached_mode
|
|
1393
|
+
|
|
1394
|
+
write_cached_mode(stored_valid)
|
|
1395
|
+
except Exception:
|
|
1396
|
+
pass
|
|
1315
1397
|
with _posting_mode_lock:
|
|
1316
|
-
_posting_mode_cache.update(
|
|
1317
|
-
mode=stored if stored in ("high", "medium", "low") else None,
|
|
1318
|
-
known=True,
|
|
1319
|
-
at=time.time(),
|
|
1320
|
-
)
|
|
1398
|
+
_posting_mode_cache.update(mode=stored_valid, known=True, at=time.time())
|
|
1321
1399
|
return stored
|
|
1322
1400
|
|
|
1323
1401
|
|
|
@@ -1336,7 +1414,7 @@ def toggle_lane(lane):
|
|
|
1336
1414
|
# + store_pending_posts) is the only decision record and resume source now.
|
|
1337
1415
|
|
|
1338
1416
|
|
|
1339
|
-
def
|
|
1417
|
+
def approve_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
|
|
1340
1418
|
"""Post / reject drafts via the loopback tool. `post` = 1-based numbers to post
|
|
1341
1419
|
as-is; `edits` = [{n, text}] to rewrite then post; `reject` = numbers to mark
|
|
1342
1420
|
DONE so they're never shown for review again (not posted); `clear_link` =
|
|
@@ -1346,7 +1424,7 @@ def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, t
|
|
|
1346
1424
|
args = {"batch_id": batch_id, "post": post or [], "edits": edits or [], "reject": reject or [], "clear_link": clear_link or []}
|
|
1347
1425
|
if activity_label:
|
|
1348
1426
|
args["__s4l_activity_label"] = activity_label
|
|
1349
|
-
return loopback_tool("
|
|
1427
|
+
return loopback_tool("approve_drafts", args, timeout=timeout)
|
|
1350
1428
|
|
|
1351
1429
|
|
|
1352
1430
|
# ---- review-events outbox (2026-07-02) --------------------------------------
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.6
|
|
3
|
+
"version": "1.7.6",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -7,6 +7,16 @@ Resolution order for each platform (first non-empty wins):
|
|
|
7
7
|
checked-in file). Twitter retains the legacy `AUTOPOSTER_TWITTER_HANDLE`
|
|
8
8
|
name as an alias.
|
|
9
9
|
2. The matching field in `config.json` -> `accounts.<platform>.<field>`.
|
|
10
|
+
3. The platform's durable identity store, when it has one. Today only
|
|
11
|
+
twitter does: the keychain-independent cookie mirror stamps the live
|
|
12
|
+
@handle at connect time (see twitter_cookie_mirror). This is the SAME
|
|
13
|
+
ground truth the session posts through, so it's a safe last resort where
|
|
14
|
+
a hardcoded fallback would not be. It exists because config.json is not a
|
|
15
|
+
reliable single home for the handle: onboarding populated the mirror but
|
|
16
|
+
not always config (the "posts land 0/N, no_account_configured" bug), so
|
|
17
|
+
making config the ONLY source made it a single point of failure. There is
|
|
18
|
+
deliberately NO hardcoded default anywhere in this resolver; a missing
|
|
19
|
+
handle returns None and callers refuse rather than impersonate an account.
|
|
10
20
|
|
|
11
21
|
The handle is normalized:
|
|
12
22
|
- leading `@` is stripped (twitter)
|
|
@@ -22,18 +32,25 @@ Returns None if neither source has a value. Callers should treat None as
|
|
|
22
32
|
|
|
23
33
|
Platform key map:
|
|
24
34
|
twitter -> accounts.twitter.handle (env: AUTOPOSTER_TWITTER_HANDLE)
|
|
25
|
-
reddit ->
|
|
35
|
+
reddit -> reddit_account.username (login ground truth, when present)
|
|
36
|
+
else accounts.reddit.username (env: AUTOPOSTER_REDDIT_USERNAME)
|
|
26
37
|
linkedin -> accounts.linkedin.name (env: AUTOPOSTER_LINKEDIN_NAME)
|
|
27
38
|
github -> accounts.github.username (env: AUTOPOSTER_GITHUB_USERNAME)
|
|
28
39
|
moltbook -> accounts.moltbook.username (env: AUTOPOSTER_MOLTBOOK_USERNAME)
|
|
29
40
|
"""
|
|
30
41
|
from __future__ import annotations
|
|
31
42
|
|
|
32
|
-
import json
|
|
33
43
|
import os
|
|
34
|
-
|
|
44
|
+
import sys
|
|
35
45
|
from typing import Optional
|
|
36
46
|
|
|
47
|
+
# THE config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir canonical
|
|
48
|
+
# home / S4L_REPO_DIR resolution + mtime-based caching. Rolling our own here
|
|
49
|
+
# (the pre-2026-07-17 lru_cache + dirname(__file__) read) bypassed all of that
|
|
50
|
+
# and could read a different config.json than the rest of the pipeline.
|
|
51
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
52
|
+
from config import load_config as _load_config # noqa: E402
|
|
53
|
+
|
|
37
54
|
_PLATFORM_CONFIG_FIELD = {
|
|
38
55
|
"twitter": ("twitter", "handle"),
|
|
39
56
|
"x": ("twitter", "handle"), # alias for the canonical post-platform
|
|
@@ -70,15 +87,22 @@ def normalize(handle: Optional[str]) -> Optional[str]:
|
|
|
70
87
|
return h or None
|
|
71
88
|
|
|
72
89
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
def _durable_handle(key: str) -> Optional[str]:
|
|
91
|
+
"""Best-effort read of the platform's durable identity store, or None.
|
|
92
|
+
|
|
93
|
+
The durable store is written once at connect time and survives a fresh
|
|
94
|
+
install / keychain re-lock / Cookies-DB wipe, so it's the ground-truth last
|
|
95
|
+
resort when neither env nor config carries the handle. Only twitter has one
|
|
96
|
+
today (the cookie mirror); other platforms return None (env + config only).
|
|
97
|
+
Never raises: an unimportable/empty store must not break resolution.
|
|
98
|
+
"""
|
|
99
|
+
if key in ("twitter", "x"):
|
|
100
|
+
try:
|
|
101
|
+
import twitter_cookie_mirror # optional dep; scripts-dir sibling
|
|
102
|
+
return normalize((twitter_cookie_mirror.load_meta() or {}).get("handle"))
|
|
103
|
+
except Exception:
|
|
104
|
+
return None
|
|
105
|
+
return None
|
|
82
106
|
|
|
83
107
|
|
|
84
108
|
def resolve(platform: str) -> Optional[str]:
|
|
@@ -92,11 +116,31 @@ def resolve(platform: str) -> Optional[str]:
|
|
|
92
116
|
if env_value:
|
|
93
117
|
return env_value
|
|
94
118
|
|
|
95
|
-
section, field = _PLATFORM_CONFIG_FIELD[key]
|
|
96
119
|
cfg = _load_config()
|
|
120
|
+
|
|
121
|
+
# Reddit's login ground truth outranks the plain config field: the top-level
|
|
122
|
+
# `reddit_account` block is the auth-bearing "who this machine actually logs
|
|
123
|
+
# in as" record (operator machines / VMs). Drift between it and
|
|
124
|
+
# accounts.reddit.username broke the VM's post-permalink lookup (wrong
|
|
125
|
+
# username -> JS found 0 matching comments -> pipeline recorded `failed`
|
|
126
|
+
# despite the comment landing). This is THE ONLY place both keys are read;
|
|
127
|
+
# every caller goes through resolve("reddit") so they can never disagree
|
|
128
|
+
# about the winner again.
|
|
129
|
+
if key == "reddit":
|
|
130
|
+
legacy = normalize(((cfg.get("reddit_account") or {}).get("username")))
|
|
131
|
+
if legacy:
|
|
132
|
+
return legacy
|
|
133
|
+
|
|
134
|
+
section, field = _PLATFORM_CONFIG_FIELD[key]
|
|
97
135
|
accounts = cfg.get("accounts") or {}
|
|
98
136
|
block = accounts.get(section) or {}
|
|
99
|
-
|
|
137
|
+
config_value = normalize(block.get(field))
|
|
138
|
+
if config_value:
|
|
139
|
+
return config_value
|
|
140
|
+
|
|
141
|
+
# Last resort: the durable identity store (twitter cookie mirror). Keeps
|
|
142
|
+
# config.json from being a single point of failure without ever guessing.
|
|
143
|
+
return _durable_handle(key)
|
|
100
144
|
|
|
101
145
|
|
|
102
146
|
def require(platform: str) -> str:
|
|
@@ -80,7 +80,13 @@ import zipfile
|
|
|
80
80
|
from datetime import datetime, timedelta, timezone
|
|
81
81
|
|
|
82
82
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
83
|
-
|
|
83
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
84
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
85
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
86
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
87
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
88
|
+
from config import config_path as _canonical_config_path, load_config
|
|
89
|
+
CONFIG_PATH = _canonical_config_path()
|
|
84
90
|
ENV_PATH = os.path.join(REPO_ROOT, ".env")
|
|
85
91
|
CACHE_DIR = os.path.join(REPO_ROOT, "skill", "cache")
|
|
86
92
|
CACHE_PATH = os.path.join(CACHE_DIR, "amplitude_24h_signups.json")
|
|
@@ -110,9 +116,6 @@ def load_env():
|
|
|
110
116
|
os.environ.setdefault(k.strip(), v.strip())
|
|
111
117
|
|
|
112
118
|
|
|
113
|
-
def load_config():
|
|
114
|
-
with open(CONFIG_PATH) as f:
|
|
115
|
-
return json.load(f)
|
|
116
119
|
|
|
117
120
|
|
|
118
121
|
def keychain_get(name):
|
|
@@ -29,7 +29,13 @@ import urllib.request
|
|
|
29
29
|
from datetime import datetime, timedelta, timezone
|
|
30
30
|
|
|
31
31
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
-
|
|
32
|
+
# THE canonical config loader (scripts/config.py): S4L_CONFIG_PATH / state-dir /
|
|
33
|
+
# S4L_REPO_DIR aware, mtime-cached. Replaces this file's hand-rolled loader and
|
|
34
|
+
# its hardcoded config path (the S4L-4H dead-path class on customer boxes).
|
|
35
|
+
import os as _cfg_os, sys as _cfg_sys
|
|
36
|
+
_cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
|
|
37
|
+
from config import config_path as _canonical_config_path, load_config
|
|
38
|
+
CONFIG_PATH = _canonical_config_path()
|
|
33
39
|
ENV_PATH = os.path.join(REPO_ROOT, ".env")
|
|
34
40
|
API_BASE = "https://amplitude.com/api/2/events/segmentation"
|
|
35
41
|
|
|
@@ -45,9 +51,6 @@ def load_env():
|
|
|
45
51
|
os.environ.setdefault(k.strip(), v.strip())
|
|
46
52
|
|
|
47
53
|
|
|
48
|
-
def load_config():
|
|
49
|
-
with open(CONFIG_PATH) as f:
|
|
50
|
-
return json.load(f)
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
def fetch_signup_series(api_key, secret_key, signup_event, attribution_filter, start, end):
|
|
@@ -229,7 +229,14 @@ def _recent_batches_not_progressing(min_batches: int = BATCH_PROGRESSION_MIN_BAT
|
|
|
229
229
|
import http_api # noqa: E402
|
|
230
230
|
|
|
231
231
|
resp = http_api.api_get("/api/v1/twitter-batches", {"list": "1", "limit": str(min_batches)})
|
|
232
|
-
|
|
232
|
+
# http_api.api_get returns the RAW envelope {"ok":..,"data":{"batches":[..],
|
|
233
|
+
# "owner_host":..}} — it does NOT unwrap "data". The batches live at
|
|
234
|
+
# resp["data"]["batches"], never at resp["batches"]. Reading the wrong key
|
|
235
|
+
# made this return [] -> len<min -> False on EVERY call, so batches_stuck was
|
|
236
|
+
# hardwired False and the "drafting is not actually happening" stall cause
|
|
237
|
+
# could never be selected (missed Nhat's 07-15 phase2b-prep pileup entirely).
|
|
238
|
+
# Do NOT collapse back to resp.get("batches").
|
|
239
|
+
batches = ((resp or {}).get("data") or {}).get("batches") or []
|
|
233
240
|
if len(batches) < min_batches:
|
|
234
241
|
return False # not enough history yet to conclude a stall
|
|
235
242
|
return all((b or {}).get("current_phase") not in BATCH_PROGRESSED_PHASES for b in batches)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Shared harness-tab lifecycle: ONE park-on-exit implementation for every
|
|
2
|
+
platform browser lib (twitter_browser.py, reddit_browser.py, future).
|
|
3
|
+
|
|
4
|
+
Born 2026-07-17 from the user's unification directive: the parking/revive
|
|
5
|
+
discipline lived only in twitter_browser.py, so the reddit harness idled on
|
|
6
|
+
live reddit pages in a visible window and kept its own failure modes. Per
|
|
7
|
+
feedback_minimize_code_footprint there must be exactly one implementation.
|
|
8
|
+
|
|
9
|
+
Design constraints inherited from the twitter parker (see
|
|
10
|
+
insights_chrome150_renderer_crash_wedge_2026_07_14):
|
|
11
|
+
- Raw CDP over HTTP+websocket with hard 3s timeouts, NEVER Playwright:
|
|
12
|
+
at atexit the Playwright loop may be gone, and against a wedged browser
|
|
13
|
+
this must fail fast, not hang the exit.
|
|
14
|
+
- suppress_origin: Chrome 111+ rejects ws clients whose Origin header is
|
|
15
|
+
not in --remote-allow-origins.
|
|
16
|
+
- Park target is the platform's own /robots.txt, NOT about:blank: a static
|
|
17
|
+
~100-byte document with no SPA to leak or crash, whose URL still matches
|
|
18
|
+
every tab-reuse heuristic (bh list_tabs hides "about:" as internal, and
|
|
19
|
+
an about:blank park made scans mint new tabs — the proven focus-steal
|
|
20
|
+
trigger Target.createTarget).
|
|
21
|
+
- Skip tabs already parked; never raise; S4L_NO_TAB_PARK=1 escape hatch.
|
|
22
|
+
"""
|
|
23
|
+
import atexit
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import urllib.request
|
|
28
|
+
|
|
29
|
+
# Loopback CDP must never route through a proxy (macOS system proxy settings
|
|
30
|
+
# leak into urllib's default opener; a box-wide forwarder 403s 127.0.0.1).
|
|
31
|
+
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
32
|
+
|
|
33
|
+
_registered = set()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _targets(cdp_base: str):
|
|
37
|
+
with _OPENER.open(cdp_base.rstrip("/") + "/json/list", timeout=3) as r:
|
|
38
|
+
return json.loads(r.read())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _navigate_ws(ws_url: str, url: str) -> None:
|
|
42
|
+
import websocket
|
|
43
|
+
|
|
44
|
+
ws = websocket.create_connection(ws_url, timeout=3, suppress_origin=True)
|
|
45
|
+
try:
|
|
46
|
+
ws.send(json.dumps({
|
|
47
|
+
"id": 1, "method": "Page.navigate", "params": {"url": url},
|
|
48
|
+
}))
|
|
49
|
+
ws.recv()
|
|
50
|
+
finally:
|
|
51
|
+
ws.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def park_tabs(cdp_base: str, host_markers, park_url: str, label: str) -> None:
|
|
55
|
+
"""Navigate every page tab whose URL matches host_markers to park_url.
|
|
56
|
+
Best-effort; a wedged browser just fails the 3s connects quietly."""
|
|
57
|
+
try:
|
|
58
|
+
for t in _targets(cdp_base):
|
|
59
|
+
u = t.get("url", "")
|
|
60
|
+
if t.get("type") != "page":
|
|
61
|
+
continue
|
|
62
|
+
if not any(h in u for h in host_markers):
|
|
63
|
+
continue
|
|
64
|
+
if u.split("?", 1)[0].rstrip("/").endswith("/robots.txt"):
|
|
65
|
+
continue # already parked
|
|
66
|
+
if not t.get("webSocketDebuggerUrl"):
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
_navigate_ws(t["webSocketDebuggerUrl"], park_url)
|
|
70
|
+
print(
|
|
71
|
+
f"[{label}] parked tab on {park_url} (was {u[:80]})",
|
|
72
|
+
file=sys.stderr,
|
|
73
|
+
)
|
|
74
|
+
except Exception:
|
|
75
|
+
continue
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def register_park_on_exit(cdp_base: str, host_markers, park_url: str, label: str) -> None:
|
|
81
|
+
"""Arm park_tabs to run at process exit, once per (endpoint, park_url).
|
|
82
|
+
Call from a platform lib's get_browser_and_page so only processes that
|
|
83
|
+
actually used the browser park it."""
|
|
84
|
+
if os.environ.get("S4L_NO_TAB_PARK"):
|
|
85
|
+
return
|
|
86
|
+
key = (cdp_base, park_url)
|
|
87
|
+
if key in _registered:
|
|
88
|
+
return
|
|
89
|
+
_registered.add(key)
|
|
90
|
+
atexit.register(park_tabs, cdp_base, tuple(host_markers), park_url, label)
|