@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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +33 -9
  3. package/mcp/dist/index.js +290 -177
  4. package/mcp/dist/panel.html +31 -17
  5. package/mcp/dist/setup.js +27 -7
  6. package/mcp/dist/telemetry.js +15 -6
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +10 -2
  9. package/mcp/menubar/s4l_browser_foreground.py +7 -1
  10. package/mcp/menubar/s4l_card.py +63 -4
  11. package/mcp/menubar/s4l_card_canvas.py +62 -11
  12. package/mcp/menubar/s4l_menubar.py +380 -95
  13. package/mcp/menubar/s4l_state.py +119 -41
  14. package/mcp/package.json +1 -1
  15. package/package.json +1 -1
  16. package/scripts/account_resolver.py +58 -14
  17. package/scripts/amplitude_24h_signups.py +7 -4
  18. package/scripts/amplitude_signups.py +7 -4
  19. package/scripts/autopilot_stall_watch.py +8 -1
  20. package/scripts/browser_lifecycle.py +90 -0
  21. package/scripts/cdp_ready_check.py +81 -50
  22. package/scripts/claude_job.py +70 -10
  23. package/scripts/dm_conversation.py +15 -12
  24. package/scripts/draft_prompt_core.py +132 -64
  25. package/scripts/engage_github.py +11 -5
  26. package/scripts/engage_reddit.py +12 -5
  27. package/scripts/engagement_signal_feedback.py +1 -4
  28. package/scripts/engagement_styles.py +144 -105
  29. package/scripts/feedback_digest.py +54 -11
  30. package/scripts/find_threads.py +9 -7
  31. package/scripts/github_tools.py +23 -10
  32. package/scripts/learned_preferences.py +112 -78
  33. package/scripts/li_discovery.py +4 -1
  34. package/scripts/link_tail.py +1 -1
  35. package/scripts/live_status.py +151 -0
  36. package/scripts/merge_review_queue.py +15 -2
  37. package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
  38. package/scripts/pick_project.py +7 -4
  39. package/scripts/pick_thread_target.py +64 -13
  40. package/scripts/pick_twitter_thread_target.py +7 -4
  41. package/scripts/platform_strike_events.py +139 -7
  42. package/scripts/post_github.py +376 -35
  43. package/scripts/post_reddit.py +202 -192
  44. package/scripts/preflight.sh +23 -3
  45. package/scripts/preview_reddit_card.py +38 -0
  46. package/scripts/project_stats.py +7 -4
  47. package/scripts/reddit_ban_check.py +324 -0
  48. package/scripts/reddit_browser.py +56 -19
  49. package/scripts/reddit_chat_sync.py +10 -11
  50. package/scripts/reddit_tools.py +20 -4
  51. package/scripts/release-mcpb.sh +47 -7
  52. package/scripts/run_moltbook_cycle.py +9 -5
  53. package/scripts/s4l_ctl.sh +2 -2
  54. package/scripts/s4l_posting_mode.py +67 -6
  55. package/scripts/scan_dm_candidates.py +15 -9
  56. package/scripts/scan_github_replies.py +9 -7
  57. package/scripts/scan_moltbook_replies.py +7 -6
  58. package/scripts/scan_reddit_replies.py +11 -7
  59. package/scripts/scan_twitter_mentions_browser.py +7 -6
  60. package/scripts/scan_twitter_thread_followups.py +7 -6
  61. package/scripts/schedule_state.py +8 -4
  62. package/scripts/score_twitter_candidates.py +8 -3
  63. package/scripts/setup_twitter_auth.py +20 -96
  64. package/scripts/snapshot.py +19 -0
  65. package/scripts/stats.py +12 -10
  66. package/scripts/strike_alert.py +165 -12
  67. package/scripts/sub_moderation_history.py +129 -0
  68. package/scripts/subreddit_bans_client.py +157 -0
  69. package/scripts/test_cross_language_parity.py +185 -0
  70. package/scripts/test_no_silent_fallbacks.py +84 -0
  71. package/scripts/test_percard_posting.py +3 -3
  72. package/scripts/test_stall_watch_batch_progression.py +99 -0
  73. package/scripts/top_performers.py +24 -0
  74. package/scripts/twitter_browser.py +22 -52
  75. package/scripts/twitter_gen_links.py +3 -3
  76. package/scripts/twitter_post_plan.py +4 -4
  77. package/scripts/virality.py +151 -0
  78. package/setup/SKILL.md +1 -1
  79. package/skill/engage-dm-replies.sh +11 -0
  80. package/skill/lib/reddit-backend.sh +1 -0
  81. package/skill/run-draft-and-publish.sh +16 -0
  82. package/skill/run-reddit-search-launchd.sh +1 -0
  83. package/skill/run-reddit-search.sh +10 -1
  84. package/skill/run-reddit-threads.sh +12 -0
  85. package/skill/run-twitter-cycle.sh +104 -88
@@ -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. Mirrors those functions
94
- # (same 4 labels, same flag path) for the common pause->resume case. What it
95
- # deliberately does NOT do: mcp/src/index.ts's resumeS4L also re-runs the
96
- # readiness-gated install logic (persona/project/X-verified checks, content-
97
- # aware plist rewrite) via ensure*Installed() — that only matters for a box
98
- # that was never fully set up before Pause, an edge case the next Claude
99
- # boot / project_config call naturally heals once unpaused. This resume just
100
- # reloads the exact plists Pause unloaded (Pause never deletes anything).
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 PAUSE_TARGET_LABELS:
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 PAUSE_TARGET_LABELS:
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 post_drafts call silently routed out
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
- try:
286
- with _NO_PROXY_OPENER.open(url + "health", timeout=1.5) as r:
287
- if r.status == 200:
288
- return url
289
- except Exception as e:
290
- sys.stderr.write(f"[s4l-state] loopback health check failed: {type(e).__name__}: {e}\n")
291
- sys.stderr.flush()
292
- return None
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
- # post_drafts tool. The chat-table review still works in parallel; both surfaces
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 post_drafts call at a time."""
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 post_drafts
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 post_drafts does a whole-file read-modify-write while a batch
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 post_drafts). Returns the posted count, or None
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 post_drafts numbering
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, because the whole point is
1257
- # remote adjustability (dashboard and menubar must agree with the server).
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 (None until the first refresh lands; the next rebuild shows the mark).
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
- mode=mode if mode in ("high", "medium", "low") else None,
1284
- known=True,
1285
- at=time.time(),
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 post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
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("post_drafts", args, timeout=timeout)
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-rc.8",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.6-rc.8",
3
+ "version": "1.7.6",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -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 -> accounts.reddit.username (env: AUTOPOSTER_REDDIT_USERNAME)
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
- from functools import lru_cache
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
- @lru_cache(maxsize=1)
74
- def _load_config() -> dict:
75
- repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
76
- cfg_path = os.path.join(repo_root, "config.json")
77
- try:
78
- with open(cfg_path, "r", encoding="utf-8") as f:
79
- return json.load(f) or {}
80
- except (OSError, json.JSONDecodeError):
81
- return {}
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
- return normalize(block.get(field))
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
- CONFIG_PATH = os.path.join(REPO_ROOT, "config.json")
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
- CONFIG_PATH = os.path.join(REPO_ROOT, "config.json")
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
- batches = (resp or {}).get("batches") or []
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)