@m13v/s4l 1.7.5-rc.2 → 1.7.5-rc.21

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 (48) hide show
  1. package/mcp/dist/index.js +571 -79
  2. package/mcp/dist/panel.html +2 -3
  3. package/mcp/dist/redditAuth.js +115 -0
  4. package/mcp/dist/runtime.js +83 -2
  5. package/mcp/dist/setup.js +142 -5
  6. package/mcp/dist/telemetry.js +13 -3
  7. package/mcp/dist/version.json +2 -2
  8. package/mcp/manifest.json +1 -1
  9. package/mcp/menubar/s4l_card.py +32 -6
  10. package/mcp/menubar/s4l_menubar.py +42 -111
  11. package/mcp/menubar/s4l_state.py +54 -187
  12. package/mcp/package.json +1 -1
  13. package/mcp/shared/onboarding-ledger.cjs +63 -4
  14. package/package.json +1 -1
  15. package/scripts/autopilot_stall_watch.py +3 -6
  16. package/scripts/browser_mutex.py +316 -0
  17. package/scripts/claude_job.py +21 -0
  18. package/scripts/config.py +118 -0
  19. package/scripts/counterparty_history.py +44 -6
  20. package/scripts/engage_github.py +7 -3
  21. package/scripts/engage_reddit.py +9 -3
  22. package/scripts/engagement_signal_feedback.py +256 -0
  23. package/scripts/learned_preferences.py +37 -14
  24. package/scripts/memory_snapshot.py +45 -0
  25. package/scripts/merge_review_queue.py +67 -3
  26. package/scripts/post_reddit.py +289 -24
  27. package/scripts/reddit_browser.py +62 -63
  28. package/scripts/reddit_browser_fetch.py +32 -0
  29. package/scripts/release-mcpb.sh +17 -0
  30. package/scripts/reset-test-machine.sh +13 -7
  31. package/scripts/scan_twitter_thread_followups.py +7 -1
  32. package/scripts/schedule_state.py +1 -1
  33. package/scripts/setup_reddit_auth.py +925 -0
  34. package/scripts/snapshot.py +96 -4
  35. package/scripts/stats.py +37 -13
  36. package/scripts/test_no_silent_fallbacks.py +281 -0
  37. package/scripts/trigger_promo_lane_test.sh +91 -0
  38. package/scripts/twitter_browser.py +183 -342
  39. package/scripts/twitter_scan.py +75 -10
  40. package/scripts/watchdog_hung_runs.py +15 -9
  41. package/skill/lib/harness-common.sh +344 -0
  42. package/skill/lib/linkedin-backend.sh +61 -161
  43. package/skill/lib/reddit-backend.sh +39 -125
  44. package/skill/lib/twitter-backend.sh +51 -273
  45. package/skill/lock.sh +57 -0
  46. package/skill/run-reddit-search-launchd.sh +4 -1
  47. package/skill/run-reddit-search.sh +51 -2
  48. package/skill/run-twitter-cycle.sh +44 -21
@@ -31,11 +31,45 @@ from http_api import api_get, api_post, api_patch
31
31
  from author_history_block import render as _render_author_history
32
32
  from project_topics import topics_for_project
33
33
 
34
- REPO_DIR = os.path.expanduser("~/social-autoposter")
34
+ # Honor S4L_REPO_DIR so managed-package installs resolve helper scripts inside
35
+ # the package instead of a nonexistent ~/social-autoposter (the same $HOME
36
+ # hardcode class that silently no-op'd session restore on customer boxes).
37
+ REPO_DIR = os.path.expanduser(os.environ.get("S4L_REPO_DIR") or "~/social-autoposter")
35
38
  CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
36
39
  REDDIT_BROWSER = os.path.join(REPO_DIR, "scripts", "reddit_browser.py")
37
40
  REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
38
41
  REDDIT_TOOLS = os.path.join(REPO_DIR, "scripts", "reddit_tools.py")
42
+ RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
43
+
44
+ # JSON schema for the queue-routed draft turn (tag post-reddit-draft ->
45
+ # claude_job.py type reddit-draft). One object, two arrays; posts[] entries
46
+ # mirror the legacy JSONL "action":"post" line fields, rejects[] entries feed
47
+ # _propose_excludes_from_rejects (thread_url + reason + proposed_excludes).
48
+ REDDIT_DRAFT_SCHEMA = json.dumps({
49
+ "type": "object",
50
+ "properties": {
51
+ "posts": {"type": "array", "items": {"type": "object", "properties": {
52
+ "thread_url": {"type": "string"},
53
+ "reply_to_url": {"type": ["string", "null"]},
54
+ "text": {"type": "string"},
55
+ "thread_author": {"type": "string"},
56
+ "thread_title": {"type": "string"},
57
+ "engagement_style": {"type": "string"},
58
+ "search_topic": {"type": "string"},
59
+ "new_style": {"type": ["object", "null"], "properties": {
60
+ "description": {"type": "string"},
61
+ "example": {"type": "string"},
62
+ }},
63
+ }, "required": ["thread_url", "text", "thread_author",
64
+ "thread_title", "engagement_style"]}},
65
+ "rejects": {"type": "array", "items": {"type": "object", "properties": {
66
+ "thread_url": {"type": "string"},
67
+ "reason": {"type": "string"},
68
+ "proposed_excludes": {"type": "array", "items": {"type": "string"}},
69
+ }, "required": ["thread_url", "reason"]}},
70
+ },
71
+ "required": ["posts", "rejects"],
72
+ })
39
73
 
40
74
  # Interpreter every child subprocess must run under. A bare PYTHON resolved
41
75
  # to the user's system python, which lacks the pipeline deps (Playwright and
@@ -101,8 +135,44 @@ _TRANSIENT_CDP_ERRORS = {
101
135
  "all_attempts_failed",
102
136
  "comment_box_not_found",
103
137
  "not_logged_in",
138
+ # A concurrent reddit pipeline navigated the shared harness tab out from
139
+ # under the poster (2026-07-14: 5/5 approvals misclassified as
140
+ # account_blocked_in_sub this way). Always retryable.
141
+ "tab_contention",
104
142
  }
105
143
 
144
+ # ---- posting-active flag (2026-07-14, mirrors twitter's posting-active) ----
145
+ # Stamped for the whole --phase post run and heartbeated per row. Readers:
146
+ # run-reddit-search.sh (skips a cycle fire while fresh) and reddit-backend.sh's
147
+ # pre-launch defer hook (skips every other reddit pipeline's fire), so scans
148
+ # and engagement never grab the ONE shared harness tab mid-post. A stale file
149
+ # (heartbeat older than ~120s) never blocks anyone: a killed poster must not
150
+ # wedge the fleet.
151
+ _S4L_STATE_DIR = os.path.expanduser(os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp")
152
+ POSTING_ACTIVE_FILE = os.path.join(_S4L_STATE_DIR, "reddit-posting-active.json")
153
+
154
+
155
+ def _stamp_posting_active():
156
+ try:
157
+ os.makedirs(_S4L_STATE_DIR, exist_ok=True)
158
+ with open(POSTING_ACTIVE_FILE, "w") as f:
159
+ json.dump({"pid": os.getpid(), "hb": time.time()}, f)
160
+ except Exception:
161
+ pass
162
+
163
+
164
+ def _clear_posting_active():
165
+ try:
166
+ with open(POSTING_ACTIVE_FILE) as f:
167
+ if json.load(f).get("pid") != os.getpid():
168
+ return # a peer poster owns the flag now; not ours to clear
169
+ except Exception:
170
+ pass
171
+ try:
172
+ os.remove(POSTING_ACTIVE_FILE)
173
+ except Exception:
174
+ pass
175
+
106
176
  from engagement_styles import (
107
177
  VALID_STYLES, get_styles_prompt, get_content_rules, validate_or_register,
108
178
  pick_style_for_post, get_voice_relationship_rule,
@@ -1029,13 +1099,87 @@ no candidate lines, no commentary about thread content (you don't see any).
1029
1099
  """
1030
1100
 
1031
1101
 
1102
+ def _truncate(s, n):
1103
+ s = s or ""
1104
+ return s if len(s) <= n else s[:n] + "..."
1105
+
1106
+
1107
+ def _prefetch_thread_digests(candidates, reddit_username=None):
1108
+ """Pre-fetch thread content for the draft prompt (2026-07-14).
1109
+
1110
+ The draft turn used to be agentic: Claude ran `reddit_tools.py fetch` via
1111
+ the Bash tool per candidate. Queue routing (claude_job.py, tag
1112
+ post-reddit-draft) only carries pure text->JSON turns, so the fetches now
1113
+ happen HERE, in Python, and the digests are inlined into the prompt —
1114
+ the same tool-free conversion the twitter Phase 2b prep got on 2026-06-26.
1115
+ Same access pattern as before (reddit_tools fetch rides the harness
1116
+ Chrome's CDP, which is multi-client safe), just a different caller.
1117
+
1118
+ Returns (digests, dropped): digests maps thread_url -> digest text for
1119
+ candidates worth drafting; dropped is a list of (candidate, reason) for
1120
+ threads that can no longer be posted to (archived/locked/blocked) so the
1121
+ caller marks them permanently failed. Transient fetch failures land in
1122
+ NEITHER (candidate stays pending; Phase 0 salvage retries next cycle).
1123
+ """
1124
+ digests = {}
1125
+ dropped = []
1126
+ our_name = (reddit_username or "").lower()
1127
+ for c in candidates:
1128
+ url = c.get("thread_url") or ""
1129
+ if not url:
1130
+ continue
1131
+ try:
1132
+ proc = subprocess.run(
1133
+ [PYTHON, REDDIT_TOOLS, "fetch", url],
1134
+ capture_output=True, text=True, timeout=90,
1135
+ )
1136
+ data = json.loads((proc.stdout or "").strip() or "{}")
1137
+ except Exception as e:
1138
+ print(f"[post_reddit] prefetch FAILED (transient) {url}: {e}",
1139
+ file=sys.stderr, flush=True)
1140
+ continue
1141
+ err = data.get("error")
1142
+ if err:
1143
+ if err in ("thread_archived", "thread_locked", "subreddit_blocked"):
1144
+ dropped.append((c, err))
1145
+ else:
1146
+ print(f"[post_reddit] prefetch error (transient) {url}: {err}",
1147
+ file=sys.stderr, flush=True)
1148
+ continue
1149
+ thread = data.get("thread") or {}
1150
+ comments = data.get("comments") or []
1151
+ lines = [
1152
+ f"subreddit: {thread.get('subreddit', '')} score: {thread.get('score', 0)}"
1153
+ f" comments: {thread.get('num_comments', 0)}",
1154
+ f"OP u/{thread.get('author', '')}: {thread.get('title', '')}",
1155
+ ]
1156
+ selftext = (thread.get("selftext") or "").strip()
1157
+ if selftext:
1158
+ lines.append(_truncate(selftext, 1500))
1159
+ already = False
1160
+ if comments:
1161
+ lines.append("top comments:")
1162
+ for cm in comments[:12]:
1163
+ author = cm.get("author") or ""
1164
+ if our_name and author.lower() == our_name:
1165
+ already = True
1166
+ body = _truncate((cm.get("body") or "").strip().replace("\n", " "), 280)
1167
+ lines.append(f" u/{author} ({cm.get('score', 0)}): {body}")
1168
+ if already:
1169
+ lines.append("NOTE: one of OUR accounts already commented in this"
1170
+ " thread (astroturf OMIT rule applies).")
1171
+ digests[url] = "\n".join(lines)
1172
+ return digests, dropped
1173
+
1174
+
1032
1175
  def build_draft_prompt(project, config, candidates, top_report, recent_comments,
1033
- style_assignment=None):
1176
+ style_assignment=None, digests=None):
1034
1177
  """DRAFT phase: write comments only for ripen-survivors.
1035
1178
 
1036
1179
  `candidates` is the list of decisions that passed the delta gate, each
1037
- annotated with ripen data (delta_up, delta_comments, composite). Claude
1038
- fetches each thread, reads context, then writes the best comment.
1180
+ annotated with ripen data (delta_up, delta_comments, composite). Thread
1181
+ content is pre-fetched by _prefetch_thread_digests and inlined per
1182
+ candidate (tool-free turn since 2026-07-14; the model must not fetch).
1039
1183
 
1040
1184
  2026-05-19: `style_assignment` is the pick_style_for_post() result the
1041
1185
  discover phase already wrote into the plan JSON. Forwarding it here so
@@ -1078,11 +1222,15 @@ def build_draft_prompt(project, config, candidates, top_report, recent_comments,
1078
1222
  history_line = "\n " + _hb.replace("\n", "\n ")
1079
1223
  except Exception:
1080
1224
  pass
1225
+ digest_block = ""
1226
+ if digests and c.get("thread_url") in digests:
1227
+ _d = digests[c["thread_url"]]
1228
+ digest_block = "\n THREAD CONTENT:\n " + _d.replace("\n", "\n ")
1081
1229
  candidate_lines.append(
1082
1230
  f" - {c['thread_url']}{delta_info}\n"
1083
1231
  f" title: {c.get('thread_title', '')}\n"
1084
1232
  f" suggested style: {c.get('engagement_style', '')}"
1085
- f"{history_line}"
1233
+ f"{history_line}{digest_block}"
1086
1234
  )
1087
1235
  candidates_block = "\n".join(candidate_lines)
1088
1236
 
@@ -1120,13 +1268,8 @@ OMIT THESE (clear no-bridge cases only):
1120
1268
  - Project: study tool. Thread where you've ALREADY commented under any of our accounts (`already_posted=true` or our usernames in the comment list): obvious astroturfing. OMIT.
1121
1269
  - Any thread where you'd be embarrassed to have your comment shown next to a {project.get('name', 'this project')} link in the same Reddit thread.
1122
1270
 
1123
- ## Tools (via Bash)
1124
- - Fetch thread: python3 {REDDIT_TOOLS} fetch "THREAD_URL"
1125
- - Do NOT run any searches. The threads are already chosen.
1126
-
1127
- ## CRITICAL Bash rules
1128
- - NEVER use run_in_background=true. All commands foreground.
1129
- - Fetch each thread once to read OP and top comments for context. Use the fetched content to apply the SELECTION GATE.
1271
+ ## THREAD CONTENT (pre-fetched)
1272
+ Each candidate above carries a THREAD CONTENT block (OP + top comments), already fetched for you. You have NO tools this session: do NOT fetch, search, or browse anything. Apply the SELECTION GATE using the inlined content only. If a candidate is somehow missing its THREAD CONTENT block, OMIT it.
1130
1273
 
1131
1274
  ## CRITICAL CONTENT RULES (apply only to threads that pass the gate)
1132
1275
  - Go BIMODAL on length: 1 punchy sentence (<100 chars) OR 4-5 sentences of real substance. Avoid 2-3 sentence middle-ground.
@@ -1148,15 +1291,19 @@ OMIT THESE (clear no-bridge cases only):
1148
1291
  {get_voice_relationship_rule()}
1149
1292
 
1150
1293
  ## OUTPUT FORMAT
1151
- For each thread that PASSES the SELECTION GATE, output one JSON object per line:
1152
- {{"action": "post", "thread_url": "SAME_URL_AS_GIVEN", "reply_to_url": null, "text": "your comment here", "thread_author": "username", "thread_title": "thread title", "engagement_style": "{(style_assignment or {}).get('style') or 'style_name'}", "search_topic": "the seed concept", "new_style": null}}
1294
+ Return ONE JSON object with two arrays (a JSON schema is enforced on this session):
1295
+
1296
+ {{"posts": [...], "rejects": [...]}}
1297
+
1298
+ Each posts[] entry is one thread that PASSES the SELECTION GATE:
1299
+ {{"thread_url": "SAME_URL_AS_GIVEN", "reply_to_url": null, "text": "your comment here", "thread_author": "username", "thread_title": "thread title", "engagement_style": "{(style_assignment or {}).get('style') or 'style_name'}", "search_topic": "the seed concept", "new_style": null}}
1153
1300
 
1154
- For threads that FAIL the gate, simply omit the post JSON above. The shell handles unhandled candidates correctly (Phase 0 salvage on the next cycle re-checks them, and one-strike ripen failure has already pruned dead threads).
1301
+ For threads that FAIL the gate, simply leave them out of posts. The shell handles unhandled candidates correctly (Phase 0 salvage on the next cycle re-checks them, and one-strike ripen failure has already pruned dead threads). When nothing passes, return {{"posts": [], "rejects": []}}.
1155
1302
 
1156
- ## OPTIONAL: proposed_excludes (self-improving denylist)
1157
- When you OMIT a thread because of a recurring CLASS of false-positive (the SUB itself surfaces wrong-audience threads, not just this one thread), you MAY emit a second JSON line for that thread:
1303
+ ## OPTIONAL: rejects[] (self-improving denylist)
1304
+ When you OMIT a thread because of a recurring CLASS of false-positive (the SUB itself surfaces wrong-audience threads, not just this one thread), you MAY add a rejects[] entry for that thread:
1158
1305
 
1159
- {{"action": "reject", "thread_url": "SAME_URL_AS_GIVEN", "reason": "short reason", "proposed_excludes": ["subreddit:bestofredditorupdates"]}}
1306
+ {{"thread_url": "SAME_URL_AS_GIVEN", "reason": "short reason", "proposed_excludes": ["subreddit:bestofredditorupdates"]}}
1160
1307
 
1161
1308
  Rules:
1162
1309
  - proposed_excludes entries MUST use the typed form `subreddit:<slug>` (lowercase, no `r/` prefix). Future shape: `keyword:<word>` is accepted but unused today.
@@ -1176,7 +1323,7 @@ Examples of WRONG proposals (do not emit):
1176
1323
  - Reject a specific r/nursing thread because OP is venting → DO NOT exclude r/nursing (it's our target audience; just omit this thread)
1177
1324
  - Reject one r/anki thread that's off-topic → DO NOT exclude r/anki (core ICP)
1178
1325
 
1179
- Output DONE after all JSONs (both post and reject lines, in any order). Do NOT narrate. Fetch, gate, draft-or-reject, output JSONs, DONE.
1326
+ Do NOT narrate. Gate, draft-or-reject, return the single JSON object.
1180
1327
  """
1181
1328
 
1182
1329
 
@@ -1366,9 +1513,77 @@ Do NOT describe what you are doing. Do NOT narrate. Just search, draft, output J
1366
1513
  """
1367
1514
 
1368
1515
 
1516
+ def run_claude_structured(prompt, timeout=1200):
1517
+ """Run the draft turn through scripts/run_claude.sh, tag post-reddit-draft.
1518
+
1519
+ The tag is mapped in claude_job.py TAG_TO_TYPE, so run_claude.sh routes it
1520
+ through the file job queue and the s4l-worker scheduled task performs the
1521
+ LLM turn (mapped tags deliberately have NO claude -p fallback: on a box
1522
+ with no live worker the provider times out with exit 79 and this phase
1523
+ skips, exactly like the twitter draft lane when Claude Desktop is closed).
1524
+ run_claude.sh also owns session accounting (claude_sessions cost rows); it
1525
+ honors our pre-set CLAUDE_SESSION_ID so posts keep their attribution.
1526
+
1527
+ Returns (ok, result, usage): result is the parsed structured_output dict
1528
+ (posts/rejects per REDDIT_DRAFT_SCHEMA), or raw text when the envelope
1529
+ carried an unparseable result (caller falls back to the legacy JSONL
1530
+ regex parsers), or an error string when ok is False.
1531
+ """
1532
+ usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0,
1533
+ "cache_create": 0, "cost_usd": 0.0}
1534
+ session_id = str(uuid.uuid4())
1535
+ usage["session_id"] = session_id
1536
+ # Inherited by run_claude.sh (which honors a caller-set session id) and by
1537
+ # the log_post -> reddit_tools.py chain for posts.claude_session_id.
1538
+ os.environ["CLAUDE_SESSION_ID"] = session_id
1539
+ env = os.environ.copy()
1540
+ env.pop("ANTHROPIC_API_KEY", None) # claude uses OAuth, not API key
1541
+ cmd = ["bash", RUN_CLAUDE_SH, "post-reddit-draft", "-p",
1542
+ "--output-format", "json", "--json-schema", REDDIT_DRAFT_SCHEMA]
1543
+ try:
1544
+ proc = subprocess.run(cmd, input=prompt, env=env, text=True,
1545
+ capture_output=True, timeout=timeout)
1546
+ except subprocess.TimeoutExpired:
1547
+ return False, "TIMEOUT", usage
1548
+ # run_claude.sh narrates on stderr (queue enqueue, quota stamps); forward
1549
+ # into the run log so cycle debugging keeps its trail.
1550
+ for ln in (proc.stderr or "").splitlines():
1551
+ print(f"[post_reddit] {ln[:300]}", file=sys.stderr, flush=True)
1552
+ text = (proc.stdout or "").strip()
1553
+ if proc.returncode != 0:
1554
+ # exit 79 = queue provider timeout (no live worker) — the standard
1555
+ # claude-blocked skip code; the caller records draft_error and the
1556
+ # candidates stay pending for the next cycle.
1557
+ return False, (text[:2000] or f"exit {proc.returncode}"), usage
1558
+ try:
1559
+ envlp, _ = json.JSONDecoder().raw_decode(text)
1560
+ except Exception as e:
1561
+ return False, f"envelope parse error: {e}: {text[:500]}", usage
1562
+ try:
1563
+ usage["cost_usd"] = float(envlp.get("total_cost_usd") or 0.0)
1564
+ except (TypeError, ValueError):
1565
+ pass
1566
+ so = envlp.get("structured_output")
1567
+ if so is None:
1568
+ so = envlp.get("result")
1569
+ if isinstance(so, str):
1570
+ try:
1571
+ so = json.loads(so)
1572
+ except (json.JSONDecodeError, TypeError):
1573
+ pass # caller regex-parses the raw text
1574
+ return True, so, usage
1575
+
1576
+
1369
1577
  def run_claude(prompt, timeout=600):
1370
1578
  """Run claude -p in bare mode with Bash tool only (no MCP needed).
1371
1579
 
1580
+ DEPRECATED 2026-07-14: the draft phase (the only caller) moved to
1581
+ run_claude_structured, which routes through run_claude.sh -> the
1582
+ claude_job.py queue (tag post-reddit-draft) so the s4l-worker scheduled
1583
+ task performs the turn. Kept for reference and emergency manual use only;
1584
+ do NOT wire new callers to this (it bypasses session cost accounting and
1585
+ the queue seam).
1586
+
1372
1587
  Streams output in real time to stderr (picked up by tee in the shell wrapper)
1373
1588
  while collecting the full output for JSON parsing.
1374
1589
  """
@@ -2210,8 +2425,29 @@ def _draft_iteration(plan, config, reddit_username):
2210
2425
  # the draft phase (the discover phase loads it for the search step).
2211
2426
  # Pass empty string; the trace audit still captures top_performers
2212
2427
  # and recent_comments, which is the bulk of the few-shot context.
2428
+ # Tool-free turn (2026-07-14): fetch every thread HERE and inline the
2429
+ # digests, so the draft prompt is pure text->JSON and queue-routable.
2430
+ # Dead threads (archived/locked/blocked) are marked permanently failed
2431
+ # now instead of at post time; transient fetch failures stay pending
2432
+ # (dropped from THIS cycle only; Phase 0 salvage retries them).
2433
+ _before_prefetch = len(candidates)
2434
+ digests, dropped = _prefetch_thread_digests(candidates, reddit_username)
2435
+ for _c, _why in dropped:
2436
+ _url = _c.get("thread_url", "")
2437
+ print(f"[post_reddit] prefetch drop {_url}: {_why}")
2438
+ _db_mark_candidate_attempt(_url, reason=f"prefetch_{_why}", permanent=True)
2439
+ candidates = [c for c in candidates if c.get("thread_url") in digests]
2440
+ print(f"[post_reddit] prefetch: {len(candidates)} thread(s) inlined, "
2441
+ f"{len(dropped)} dropped dead, "
2442
+ f"{_before_prefetch - len(candidates) - len(dropped)} transient-skipped")
2443
+ if not candidates:
2444
+ plan = dict(plan)
2445
+ plan["decisions"] = []
2446
+ plan["phase"] = "draft"
2447
+ return plan
2448
+
2213
2449
  prompt = build_draft_prompt(project, config, candidates, top_report, recent_comments,
2214
- style_assignment=style_assignment)
2450
+ style_assignment=style_assignment, digests=digests)
2215
2451
 
2216
2452
  # Build the generation_trace audit blob: what Claude is about to see.
2217
2453
  # Captured BEFORE the Claude call so we never end up with a post row
@@ -2243,17 +2479,31 @@ def _draft_iteration(plan, config, reddit_username):
2243
2479
 
2244
2480
  print(f"[post_reddit] Starting draft session for {len(candidates)} thread(s)...")
2245
2481
  start = time.time()
2246
- ok, output, usage = run_claude(prompt, timeout=600)
2482
+ ok, output, usage = run_claude_structured(prompt, timeout=1200)
2247
2483
  elapsed = time.time() - start
2248
2484
  print(f"[post_reddit] Draft finished in {elapsed:.0f}s (${usage['cost_usd']:.4f})")
2249
2485
 
2250
2486
  if not ok:
2251
- print(f"[post_reddit] Draft FAILED: {output[:300]}")
2487
+ print(f"[post_reddit] Draft FAILED: {str(output)[:300]}")
2252
2488
  plan["draft_error"] = "claude_failed"
2253
2489
  plan["draft_cost"] = usage["cost_usd"]
2254
2490
  return plan
2255
2491
 
2256
- drafted = parse_post_decisions(output)
2492
+ if isinstance(output, dict):
2493
+ # Queue/schema path: posts[] mirrors the legacy JSONL post-line shape.
2494
+ drafted = []
2495
+ _seen_urls = set()
2496
+ for _d in output.get("posts") or []:
2497
+ if not isinstance(_d, dict):
2498
+ continue
2499
+ _url = _d.get("thread_url", "")
2500
+ if _d.get("text") and _url and _url not in _seen_urls:
2501
+ _d.setdefault("action", "post")
2502
+ drafted.append(_d)
2503
+ _seen_urls.add(_url)
2504
+ else:
2505
+ # Legacy fallback: the envelope carried free text; regex-parse it.
2506
+ drafted = parse_post_decisions(output or "")
2257
2507
  print(f"[post_reddit] Draft produced {len(drafted)} post(s)")
2258
2508
 
2259
2509
  # 2026-05-11: parse optional action=reject lines and forward any
@@ -2263,7 +2513,10 @@ def _draft_iteration(plan, config, reddit_username):
2263
2513
  # here MUST NOT kill the draft phase; the post pipeline is the critical
2264
2514
  # path. See parse_reject_decisions / _propose_excludes_from_rejects.
2265
2515
  try:
2266
- rejects = parse_reject_decisions(output)
2516
+ if isinstance(output, dict):
2517
+ rejects = [r for r in (output.get("rejects") or []) if isinstance(r, dict)]
2518
+ else:
2519
+ rejects = parse_reject_decisions(output or "")
2267
2520
  if rejects:
2268
2521
  cand_by_url = {c.get("thread_url"): c for c in candidates if c.get("thread_url")}
2269
2522
  counters = _propose_excludes_from_rejects(
@@ -2315,6 +2568,12 @@ def _draft_iteration(plan, config, reddit_username):
2315
2568
  plan["decisions"] = merged
2316
2569
  plan["draft_cost"] = usage["cost_usd"]
2317
2570
  plan["draft_session_id"] = usage.get("session_id")
2571
+ # Also stamp the key _post_iteration actually reads: in two-phase mode
2572
+ # (draft in process A, post in process B) it re-exports
2573
+ # plan["session_id"] as CLAUDE_SESSION_ID, and this was never set after
2574
+ # discover went programmatic (session attribution silently dropped on
2575
+ # the two-phase path until 2026-07-14).
2576
+ plan["session_id"] = usage.get("session_id")
2318
2577
  plan["phase"] = "draft"
2319
2578
  # Stash the picker assignment so _post_iteration (which runs in a
2320
2579
  # separate process via JSON-serialized plan) can pass it to
@@ -2362,6 +2621,10 @@ def _post_iteration(plan, reddit_username):
2362
2621
  failed = 0
2363
2622
 
2364
2623
  for i, decision in enumerate(decisions):
2624
+ # Heartbeat the posting-active flag per row so readers see a fresh
2625
+ # stamp for the whole drain (rows are ~45s + 180s inter-post sleeps;
2626
+ # the reader freshness window is 120s, so re-stamp before EACH row).
2627
+ _stamp_posting_active()
2365
2628
  thread_url = decision["thread_url"]
2366
2629
  reply_to_url = decision.get("reply_to_url")
2367
2630
  text = decision["text"]
@@ -2662,9 +2925,11 @@ def main():
2662
2925
  f"posting. stderr: {(_chk.stderr or '').strip()[:300]}", file=sys.stderr)
2663
2926
  sys.exit(3)
2664
2927
  try:
2928
+ _stamp_posting_active()
2665
2929
  posted, failed = _post_iteration(plan, reddit_username)
2666
2930
  print(f"[post_reddit] phase=post project={plan.get('project_name')} posted={posted} failed={failed}")
2667
2931
  finally:
2932
+ _clear_posting_active()
2668
2933
  # Clean up the generation_trace temp file. By this point every
2669
2934
  # post that landed has the trace JSONB persisted to its row,
2670
2935
  # so the on-disk file is redundant. Best-effort delete.
@@ -243,8 +243,6 @@ def find_reddit_cdp_port():
243
243
  return None
244
244
 
245
245
 
246
- _LOCK_SESSION_ID = f"python:{os.getpid()}"
247
-
248
246
  # Path to the bash-lock lease helper. Bumping this lease from inside reddit_browser.py
249
247
  # is what shields Python-CDP pipelines (run-reddit-search, audit-reddit-resurrect,
250
248
  # stats.sh reddit phase, engage-reddit) from the watchdog's 60-90s reclaim. Those
@@ -279,72 +277,41 @@ def _heartbeat_bash_lease():
279
277
  pass # Best-effort. Never fail a CDP op because of lease bookkeeping.
280
278
 
281
279
 
282
- def _release_browser_lock():
283
- """Release the lock if we hold it."""
284
- try:
285
- if os.path.exists(LOCK_FILE):
286
- with open(LOCK_FILE) as f:
287
- lock = json.load(f)
288
- if lock.get("session_id") == _LOCK_SESSION_ID:
289
- os.remove(LOCK_FILE)
290
- except (json.JSONDecodeError, OSError):
291
- pass
280
+ # ---- browser-session mutex (2026-07-14: shared implementation) -------------
281
+ # The old inline lock here still had the check-then-write claim race AND the
282
+ # dead-holder starvation (a SIGKILLed peer blocked everyone for the full
283
+ # 300s LOCK_EXPIRY) that twitter_browser.py fixed on 2026-06-16. Both drivers
284
+ # now share scripts/browser_mutex.py (twitter's proven mutex, parameterized):
285
+ # reddit gains the O_EXCL atomic claim, dead-PID reclaim, batch inherit via
286
+ # S4L_LOCK_OWNER, and role-aware posting priority (only active when a caller
287
+ # sets S4L_LOCK_ROLE=post; default scan keeps today's no-preemption behavior).
288
+ # The bash-lease bump rides the mutex's on_touch hook, so every acquire AND
289
+ # refresh keeps the watchdog fed exactly as before.
290
+ from browser_mutex import BrowserMutex
291
+
292
+ _MUTEX = BrowserMutex(
293
+ lock_file=LOCK_FILE,
294
+ label="Reddit browser",
295
+ lock_expiry=LOCK_EXPIRY,
296
+ wait_max=LOCK_WAIT_MAX,
297
+ poll_interval=LOCK_POLL_INTERVAL,
298
+ on_touch=_heartbeat_bash_lease,
299
+ )
292
300
 
293
301
 
294
- atexit.register(_release_browser_lock)
302
+ def _acquire_browser_lock():
303
+ _MUTEX.acquire()
295
304
 
296
305
 
297
- def _acquire_browser_lock():
298
- """Wait for the Reddit browser lock to free, then acquire it.
306
+ def _refresh_browser_lock():
307
+ _MUTEX.refresh()
299
308
 
300
- Polls every LOCK_POLL_INTERVAL seconds for up to LOCK_WAIT_MAX seconds.
301
- Exits 1 only after exhausting the wait budget; the launchd caller will
302
- retry on the next 5-min tick.
303
- """
304
- deadline = time.time() + LOCK_WAIT_MAX
305
- while True:
306
- if os.path.exists(LOCK_FILE):
307
- try:
308
- with open(LOCK_FILE) as f:
309
- lock = json.load(f)
310
- age = time.time() - lock.get("timestamp", 0)
311
- if age >= LOCK_EXPIRY:
312
- break
313
- holder = lock.get("session_id", "unknown")
314
- if time.time() >= deadline:
315
- print(json.dumps({
316
- "success": False,
317
- "error": f"Reddit browser locked by session {holder} ({int(age)}s); waited {LOCK_WAIT_MAX}s, giving up."
318
- }))
319
- sys.exit(1)
320
- time.sleep(LOCK_POLL_INTERVAL)
321
- continue
322
- except (json.JSONDecodeError, OSError):
323
- pass
324
- break
325
- with open(LOCK_FILE, "w") as f:
326
- json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}, f)
327
- # Also bump the bash-lock lease so the watchdog respects this CDP burst.
328
- # See _heartbeat_bash_lease() for why: Python-CDP pipelines never trigger
329
- # the MCP PreToolUse heartbeat hook, so without this call the watchdog
330
- # would steal the bash lock at age >= 60s during legitimate CDP work.
331
- _heartbeat_bash_lease()
332
309
 
310
+ def _release_browser_lock():
311
+ _MUTEX.release()
333
312
 
334
- def _refresh_browser_lock():
335
- """Refresh the lock timestamp to prevent expiry during long operations.
336
313
 
337
- Also bumps the bash-lock lease so the watchdog won't reclaim during
338
- multi-step CDP ops. Call this from inside long CDP flows (scroll loops,
339
- multi-second waits) to keep both the python lock file mtime AND the bash
340
- lease fresh.
341
- """
342
- try:
343
- with open(LOCK_FILE, "w") as f:
344
- json.dump({"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}, f)
345
- except OSError:
346
- pass
347
- _heartbeat_bash_lease()
314
+ atexit.register(_release_browser_lock)
348
315
 
349
316
 
350
317
  # --- L1/L2 browser-hang guards (2026-07-13, mirrors twitter_browser.py) ------
@@ -589,6 +556,28 @@ def post_comment(thread_url, text):
589
556
  if "login" in page.url.lower():
590
557
  return {"ok": False, "error": "not_logged_in"}
591
558
 
559
+ # Tab-collision guard (2026-07-14): every reddit pipeline shares
560
+ # the ONE harness tab, and a concurrent scan/fetch/engage session
561
+ # can navigate it between our goto and the checks below. The
562
+ # missing comment form then misclassified as account_blocked_in_sub
563
+ # (a PERMANENT verdict) — 5/5 approved cards false-positived this
564
+ # way on 2026-07-14. Verify the tab still shows OUR thread; re-goto
565
+ # once if hijacked, and classify a persistent hijack as the
566
+ # TRANSIENT tab_contention so the row is retried, never buried.
567
+ import re as _re
568
+ _tid_m = _re.search(r"/comments/([a-z0-9]+)", old_url)
569
+ _tid = _tid_m.group(1) if _tid_m else None
570
+
571
+ def _tab_is_ours():
572
+ return bool(_tid) and f"/comments/{_tid}" in (page.url or "")
573
+
574
+ if not _tab_is_ours():
575
+ page.goto(old_url, wait_until="domcontentloaded")
576
+ page.wait_for_timeout(3000)
577
+ _ensure_old_reddit(page)
578
+ if not _tab_is_ours():
579
+ return {"ok": False, "error": "tab_contention"}
580
+
592
581
  # Check if the top-level comment form exists at all.
593
582
  # When the sub gates top-level commenting on this account (CrowdControl,
594
583
  # AutoMod karma/age threshold, mod-approved-only, shadowban), old reddit
@@ -596,10 +585,20 @@ def post_comment(thread_url, text):
596
585
  # page. The sub itself may be public; the gate is account-level. There
597
586
  # is no error banner and no API field that exposes this, so the only
598
587
  # signal is the missing form on a logged-in page load.
599
- has_comment_form = page.locator(
600
- ".commentarea .usertext.cloneable, .commentarea > form.usertext"
601
- ).count() > 0
588
+ _form_sel = ".commentarea .usertext.cloneable, .commentarea > form.usertext"
589
+ has_comment_form = page.locator(_form_sel).count() > 0
590
+ if not has_comment_form:
591
+ # Slow-render tolerance: give the form a short explicit wait
592
+ # before concluding the sub gates this account (an instant
593
+ # count() on a slow load is another false-positive source).
594
+ try:
595
+ page.locator(_form_sel).first.wait_for(state="attached", timeout=6000)
596
+ has_comment_form = True
597
+ except Exception:
598
+ pass
602
599
  if not has_comment_form:
600
+ if not _tab_is_ours():
601
+ return {"ok": False, "error": "tab_contention"}
603
602
  return {"ok": False, "error": "account_blocked_in_sub"}
604
603
 
605
604
  # Some subs render the form but show a gate notice instead of a usable
@@ -39,12 +39,44 @@ def _default_cdp_url():
39
39
  or "http://127.0.0.1:9557"
40
40
 
41
41
 
42
+ _POSTING_FLAG = os.path.join(
43
+ os.path.expanduser(os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp"),
44
+ "reddit-posting-active.json",
45
+ )
46
+ _POSTING_FRESH_S = 120
47
+ _POSTING_YIELD_MAX_S = 300
48
+
49
+
50
+ def _yield_to_poster():
51
+ """Posting owns the ONE shared harness tab (2026-07-14). Every reader
52
+ fetch navigates the tab to the reddit host root, which is exactly what
53
+ yanked the tab out from under mid-post drains (the tab_contention /
54
+ false account_blocked_in_sub family). A fresh reddit-posting-active.json
55
+ (heartbeated per row by post_reddit.py) means a poster is mid-drain:
56
+ WAIT for it instead of navigating, bounded so a stale flag can never
57
+ starve discovery/stats (they just run a few minutes later)."""
58
+ deadline = time.time() + _POSTING_YIELD_MAX_S
59
+ waited = False
60
+ while time.time() < deadline:
61
+ try:
62
+ age = time.time() - os.path.getmtime(_POSTING_FLAG)
63
+ except OSError:
64
+ break # no flag -> no poster
65
+ if age >= _POSTING_FRESH_S:
66
+ break # stale flag -> dead poster; never blocks readers
67
+ waited = True
68
+ time.sleep(5)
69
+ if waited:
70
+ sys.stderr.write("[reddit_browser_fetch] yielded to active poster\n")
71
+
72
+
42
73
  def browser_get_json(url, cdp_url=None, timeout_ms=25000):
43
74
  """Fetch a Reddit JSON URL through the logged-in harness Chrome.
44
75
 
45
76
  Returns (body_str_or_None, http_status_int). On any transport/connect
46
77
  failure returns (None, 0) so the caller can fall back to urllib.
47
78
  """
79
+ _yield_to_poster()
48
80
  cdp_url = (cdp_url or _default_cdp_url())
49
81
  try:
50
82
  from playwright.sync_api import sync_playwright