@m13v/s4l 1.7.6 → 1.7.7-rc.2

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6",
3
- "installedAt": "2026-07-18T17:32:42.014Z"
2
+ "version": "1.7.7-rc.2",
3
+ "installedAt": "2026-07-30T22:16:14.950Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.6",
5
+ "version": "1.7.7-rc.2",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -79,6 +79,7 @@ class _Worker(threading.Thread):
79
79
  super().__init__(daemon=True, name="s4l-browser-foreground")
80
80
  self._pid_cache = {} # pid -> (is_harness, details dict)
81
81
  self._prev_app = None # last non-harness frontmost app name
82
+ self._prev_app_pid = None # ...and its pid, for precise re-activation
82
83
  self._last_key = None # (cause, pid) of last emitted event
83
84
  self._last_emit_at = 0.0
84
85
  self._suppressed = 0
@@ -108,13 +109,19 @@ class _Worker(threading.Thread):
108
109
  if "chrome" not in low and "chromium" not in low:
109
110
  if cause == "activated" and name:
110
111
  self._prev_app = name
112
+ self._prev_app_pid = pid
111
113
  return
112
114
  is_harness, details = self._classify(pid)
113
115
  if not is_harness:
114
116
  # The user's own Chrome counts as their workspace too.
115
117
  if cause == "activated" and name:
116
118
  self._prev_app = name
119
+ self._prev_app_pid = pid
117
120
  return
121
+ # Suppress focus on EVERY harness activation, BEFORE the telemetry
122
+ # dedupe — the log is rate-limited, but hiding must fire every time or
123
+ # a fast pop-burst would leave later pops sitting in the foreground.
124
+ self._suppress_focus(pid, details)
118
125
  now = time.time()
119
126
  key = (cause, pid)
120
127
  if key == self._last_key and now - self._last_emit_at < _DEDUPE_SECONDS:
@@ -137,6 +144,48 @@ class _Worker(threading.Thread):
137
144
  context="browser-foreground",
138
145
  )
139
146
 
147
+ def _suppress_focus(self, pid, details):
148
+ # OS-LEVEL FOCUS SUPPRESSION (2026-07-30). The per-path code fixes
149
+ # (bh-harness activate suppression, tab reuse/park) each cover ONE way
150
+ # of driving Chrome; the reddit pipeline reaches it via a DIFFERENT
151
+ # path (reddit_browser.py Playwright connect/new_page) that bypasses
152
+ # them, and a raw external Playwright connect bypasses everything. This
153
+ # acts on the OS window event itself, so it covers EVERY connector:
154
+ # when an OFFSCREEN automation harness (window parked at negative Y —
155
+ # twitter/reddit/linkedin; NOT the onscreen setup-login window) grabs
156
+ # the foreground, hide that specific Chrome process by pid, then force
157
+ # focus back to the app the user was in. Screenshots/clicks are
158
+ # unaffected (CDP is offscreen-raster + synthetic input; the occlusion
159
+ # flags keep hidden tabs painting). Escape hatch: S4L_NO_HARNESS_HIDE.
160
+ if os.environ.get("S4L_NO_HARNESS_HIDE"):
161
+ return
162
+ pos = details.get("window_position") or ""
163
+ try:
164
+ offscreen = any(int(float(v)) < 0 for v in pos.split(",")[:2])
165
+ except Exception:
166
+ offscreen = False
167
+ if not offscreen:
168
+ return # onscreen (e.g. setup login) — leave it visible
169
+ try:
170
+ from AppKit import NSRunningApplication
171
+
172
+ ra = NSRunningApplication.runningApplicationWithProcessIdentifier_(pid)
173
+ if ra is not None:
174
+ ra.hide() # order the harness window out
175
+ # hide() alone does NOT reliably return focus here: all three
176
+ # harnesses share the "Google Chrome Beta" bundle, so hiding one
177
+ # process can leave the app-level active state on a sibling. Force
178
+ # focus back to the app the user was actually in.
179
+ if self._prev_app_pid and self._prev_app_pid != pid:
180
+ prev = NSRunningApplication.runningApplicationWithProcessIdentifier_(
181
+ self._prev_app_pid
182
+ )
183
+ # NSApplicationActivateIgnoringOtherApps = 1 << 1
184
+ if prev is not None and not prev.isTerminated():
185
+ prev.activateWithOptions_(1 << 1)
186
+ except Exception:
187
+ pass
188
+
140
189
  def run(self):
141
190
  while True:
142
191
  try:
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.7-rc.2",
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",
3
+ "version": "1.7.7-rc.2",
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",
@@ -0,0 +1,15 @@
1
+ import json,sys,subprocess,os
2
+ q=sys.argv[1]
3
+ env=dict(os.environ); env["SOCIAL_AUTOPOSTER_LINKEDIN_SEARCH"]="1"
4
+ p=subprocess.run(["/opt/homebrew/bin/python3.11","scripts/discover_linkedin_candidates.py","content",q],capture_output=True,text=True,env=env)
5
+ out=p.stdout
6
+ i=out.find("{")
7
+ try: d=json.loads(out[i:])
8
+ except Exception:
9
+ print(json.dumps({"query":q,"ok":False,"error":"parse_fail","tail":out[-300:]})); sys.exit()
10
+ if not d.get("ok"):
11
+ print(json.dumps({"query":q,"ok":False,"error":d.get("error"),"result_count":0})); sys.exit()
12
+ rows=[]
13
+ for r in d.get("results",[])[:6]:
14
+ rows.append({"an":r.get("author_name"),"hl":(r.get("author_headline") or "")[:90],"pu":r.get("author_profile_url"),"aid":r.get("activity_id"),"ah":r.get("age_hours"),"rx":r.get("reactions"),"cm":r.get("comments"),"rp":r.get("reposts"),"vs":r.get("velocity_score"),"tx":(r.get("post_text") or "")[:260]})
15
+ print(json.dumps({"query":q,"ok":True,"result_count":d.get("result_count"),"top":rows},ensure_ascii=False))
@@ -0,0 +1,14 @@
1
+ import json,subprocess,sys,os
2
+ q=sys.argv[1]
3
+ env=dict(os.environ); env["SOCIAL_AUTOPOSTER_LINKEDIN_SEARCH"]="1"
4
+ p=subprocess.run(["/opt/homebrew/bin/python3.11","scripts/discover_linkedin_candidates.py","content",q],capture_output=True,text=True,env=env)
5
+ out=p.stdout
6
+ i=out.find("{")
7
+ try: d=json.loads(out[i:])
8
+ except Exception as e:
9
+ print(json.dumps({"query":q,"ok":False,"error":"parse","raw":out[-500:]})); sys.exit()
10
+ if not d.get("ok"):
11
+ print(json.dumps({"query":q,"ok":False,"error":d.get("error"),"result_count":0})); sys.exit()
12
+ res=d.get("results",[])
13
+ slim=[{"n":r.get("author_name"),"h":(r.get("author_headline") or "")[:110],"t":(r.get("post_text") or "")[:260],"a":round(r.get("age_hours") or 0,1),"rx":r.get("reactions"),"c":r.get("comments"),"rp":r.get("reposts"),"aid":r.get("activity_id"),"u":r.get("author_profile_url")} for r in res[:8]]
14
+ print(json.dumps({"query":q,"ok":True,"result_count":d.get("result_count"),"top":slim},ensure_ascii=False))
@@ -0,0 +1,20 @@
1
+ import json,subprocess,sys,os
2
+ q=sys.argv[1]
3
+ env=dict(os.environ); env["SOCIAL_AUTOPOSTER_LINKEDIN_SEARCH"]="1"
4
+ p=subprocess.run(["/opt/homebrew/bin/python3.11","scripts/discover_linkedin_candidates.py","content",q],capture_output=True,text=True,env=env)
5
+ out=p.stdout
6
+ i=out.find("{")
7
+ try:
8
+ d=json.loads(out[i:])
9
+ except Exception:
10
+ print("PARSE_FAIL"); print(out[-1500:]); sys.exit(0)
11
+ if not d.get("ok"):
12
+ print("NOTOK",d.get("error"),d.get("retry_after_seconds")); sys.exit(0)
13
+ json.dump(d,open("/tmp/pa_%s.json"%q.replace(" ","_"),"w"))
14
+ print("query=",q,"result_count=",d.get("result_count"))
15
+ for r in d["results"][:6]:
16
+ print("---")
17
+ print(" auth:",r["author_name"],"|",(r.get("author_headline") or "")[:110])
18
+ print(" url:",r.get("post_url"),"aid:",r.get("activity_id"))
19
+ print(" age:",r.get("age_hours"),"rx:",r["reactions"],"cm:",r["comments"],"rp:",r["reposts"])
20
+ print(" txt:",(r.get("post_text") or "")[:260].replace("\n"," "))
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env python3
2
+ """LinkedIn action pacing gate.
3
+
4
+ Single source of truth for "is it safe to post another LinkedIn comment right
5
+ now, or should we wait / stop for today". Every LinkedIn write path calls
6
+ `check` immediately BEFORE each comment and honours the answer.
7
+
8
+ Why this exists
9
+ ---------------
10
+ Until 2026-07-29 the LinkedIn write path had NO pacing control at all: no
11
+ minimum gap, no jitter, no hourly cap, no per-day cap. The human-looking cadence
12
+ on healthy days was an accident of queue availability and lock contention, not a
13
+ control. Observed inter-action gaps routinely bottomed out at 0-3 SECONDS.
14
+
15
+ TWO ACTION STREAMS - counting only one is the trap
16
+ --------------------------------------------------
17
+ LinkedIn writes land in two different tables and BOTH must be counted:
18
+
19
+ replies (replied_at) engage-linkedin.sh - replying to comments on our posts
20
+ posts (posted_at) run-linkedin.sh - commenting on others' posts,
21
+ via log_post.py, status='active'
22
+
23
+ The first cut of this gate counted only `replies` and therefore saw ~25% of
24
+ reality: it read 2026-07-20 as 23 actions when the true figure was 90, and read
25
+ 2026-07-19 as ZERO when it was 73. Any future edit that narrows this query to a
26
+ single table silently disables most of the gate. Only rows with status='active'
27
+ count as real writes; log_post.py also records rejected candidates.
28
+
29
+ What the data does and does NOT support
30
+ ---------------------------------------
31
+ Combined-stream daily figures, 2026-07-06..07-20:
32
+
33
+ n/day 65 .. 96 actions
34
+ per hour 2.9 .. 4.4
35
+ CV 0.32 .. 1.26 on EVERY day, healthy or not
36
+
37
+ An earlier draft of this file claimed CV (sd/mean of inter-action gaps)
38
+ separated logout days from healthy ones at ~1.0. That was an artifact of the
39
+ replies-only subsample. On the full stream it does NOT separate: 2026-07-14 ran
40
+ 96 actions at CV 0.72 with no logout, and 2026-07-20 ran 90 at CV 0.78 and was
41
+ killed. We therefore do NOT rely on CV as a predictor; it is retained only at a
42
+ very low floor to catch a true metronome, which remains bad regardless of
43
+ whether it predicts a logout.
44
+
45
+ Stated plainly: we have NO statistic that reliably separates logout days from
46
+ healthy days. What we do know is (a) this account is flagged, in LinkedIn's own
47
+ words ("temporary restriction for automated activity" twice, "automation tool
48
+ detected" once), (b) every active stretch so far has ended in a session kill,
49
+ 6 for 6, and (c) 65-96 automated actions a day with sub-second minimum gaps is
50
+ indefensible in absolute terms whatever the trigger turns out to be. The
51
+ ceilings below are therefore a deliberate ~70% volume cut chosen on judgment,
52
+ NOT a proven safe operating point. Revise them as evidence accumulates.
53
+
54
+ Ceilings (env-overridable, see CONFIG below):
55
+ min gap 120s hard floor; observed minimum was 0-3s
56
+ per rolling 1h 4 observed average was 2.9-4.4/h with bursts
57
+ per rolling 24h 25 observed 65-96/day
58
+ per rolling 72h 60 observed ~230/3d
59
+ CV floor 0.25 true-metronome catch only; NOT a logout predictor
60
+ min daily spread 4h actions must not bunch into one short window
61
+
62
+ CLI
63
+ ---
64
+ linkedin_pacing.py check # exit 0 = post now, 75 = wait, 78 = stop
65
+ linkedin_pacing.py check --json # machine-readable decision
66
+ linkedin_pacing.py status # current counters, always exit 0
67
+
68
+ Exit codes are distinct so a shell caller can tell "wait" from "stop for now":
69
+ 0 -> allowed, post now
70
+ 75 -> not yet; sleep `wait_seconds` then re-check (EX_TEMPFAIL-ish)
71
+ 78 -> a ceiling is blown; do not post at all this run (matches the repo's
72
+ existing rc=78 "skip this fire" convention)
73
+
74
+ FAIL-CLOSED: if the database cannot be read we deny (rc=78). The account is
75
+ already flagged; posting blind is worse than skipping a cycle. This mirrors the
76
+ existing "db_unavailable -> script already fails closed" rule in run-linkedin.sh.
77
+ """
78
+
79
+ import argparse
80
+ import json
81
+ import math
82
+ import os
83
+ import random
84
+ import statistics
85
+ import sys
86
+ from datetime import datetime, timedelta, timezone
87
+
88
+
89
+ # --------------------------------------------------------------------------
90
+ # CONFIG - every value env-overridable so we can tune without editing a frozen
91
+ # file, and so tests can drive it against throwaway numbers.
92
+ # --------------------------------------------------------------------------
93
+ def _envf(name, default):
94
+ try:
95
+ return float(os.environ[name])
96
+ except (KeyError, ValueError):
97
+ return default
98
+
99
+
100
+ def _envi(name, default):
101
+ try:
102
+ return int(os.environ[name])
103
+ except (KeyError, ValueError):
104
+ return default
105
+
106
+
107
+ # --- interval GENERATION (not just a ceiling) ------------------------------
108
+ # A pure floor+ceiling cap produces a clipped, bimodal shape: run flat out at
109
+ # the floor until the hourly ceiling bites, then stall. Measured on the first
110
+ # version of this file: 59% of all gaps sat exactly at the 120s floor and NOTHING
111
+ # landed in the 3-10 minute band. That is a sharper machine signature than the
112
+ # unpaced behaviour it replaced, so the floor alone is not pacing.
113
+ #
114
+ # Instead we model arrivals as a POISSON PROCESS: each gap is drawn from an
115
+ # exponential distribution whose mean is set so the daily budget spreads across
116
+ # the active window. Exponential arrivals are memoryless, naturally varied
117
+ # (CV ~= 1.0 by construction), and produce no pile-up at any particular value.
118
+ # MIN_GAP_S survives only as a clamp for the short tail.
119
+ #
120
+ # The draw is DETERMINISTIC in the timestamp of the previous action. This is
121
+ # essential: the gate gets polled repeatedly while waiting, and a fresh random
122
+ # draw per call would let the caller "reroll until lucky", collapsing the whole
123
+ # distribution back onto the floor.
124
+ ACTIVE_START_H = _envi("LI_PACE_ACTIVE_START_H", 8) # local hour, inclusive
125
+ ACTIVE_END_H = _envi("LI_PACE_ACTIVE_END_H", 22) # local hour, exclusive
126
+ MAX_GAP_S = _envi("LI_PACE_MAX_GAP_S", 4 * 3600)
127
+ MIN_GAP_S = _envi("LI_PACE_MIN_GAP_S", 120)
128
+ CV_FLOOR = _envf("LI_PACE_CV_FLOOR", 0.25)
129
+ CV_WINDOW = _envi("LI_PACE_CV_WINDOW", 10)
130
+ MAX_PER_1H = _envi("LI_PACE_MAX_1H", 4)
131
+ MAX_PER_24H = _envi("LI_PACE_MAX_24H", 25)
132
+ MAX_PER_72H = _envi("LI_PACE_MAX_72H", 60)
133
+ MIN_SPREAD_S = _envi("LI_PACE_MIN_SPREAD_S", 4 * 3600)
134
+ # When CV is too low we do not just wait the floor, we inject a long randomized
135
+ # pause to actively break the metronome and pull CV back up.
136
+ CV_PAUSE_MIN_S = _envi("LI_PACE_CV_PAUSE_MIN_S", 900)
137
+ CV_PAUSE_MAX_S = _envi("LI_PACE_CV_PAUSE_MAX_S", 3600)
138
+
139
+ PLATFORM = "linkedin"
140
+
141
+ RC_ALLOW = 0
142
+ RC_WAIT = 75
143
+ RC_STOP = 78
144
+
145
+
146
+ def _database_url():
147
+ url = os.environ.get("DATABASE_URL")
148
+ if url:
149
+ return url.strip().strip('"').strip("'")
150
+ env_path = os.path.expanduser("~/social-autoposter/.env")
151
+ try:
152
+ with open(env_path, encoding="utf-8") as fh:
153
+ for line in fh:
154
+ line = line.strip()
155
+ if line.startswith("DATABASE_URL="):
156
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
157
+ except OSError:
158
+ pass
159
+ return None
160
+
161
+
162
+ def _recent_timestamps(hours=72):
163
+ """Every LinkedIn write action in the last `hours`, UTC, ascending.
164
+
165
+ UNION of BOTH write streams. See the module docstring: counting only one
166
+ table silently disables most of this gate.
167
+
168
+ Raises on any failure so callers can fail closed.
169
+ """
170
+ import psycopg2 # imported lazily so `--help` works without the driver
171
+
172
+ url = _database_url()
173
+ if not url:
174
+ raise RuntimeError("DATABASE_URL not resolvable")
175
+ since = datetime.now(timezone.utc) - timedelta(hours=hours)
176
+ conn = psycopg2.connect(url, connect_timeout=10)
177
+ try:
178
+ with conn.cursor() as cur:
179
+ cur.execute(
180
+ "select ts from ("
181
+ " select posted_at as ts from posts"
182
+ " where platform = %s and status = 'active'"
183
+ " and posted_at is not null and posted_at >= %s"
184
+ " union all"
185
+ " select replied_at as ts from replies"
186
+ " where platform = %s"
187
+ " and replied_at is not null and replied_at >= %s"
188
+ ") a order by ts",
189
+ (PLATFORM, since, PLATFORM, since),
190
+ )
191
+ return [r[0] for r in cur.fetchall()]
192
+ finally:
193
+ conn.close()
194
+
195
+
196
+ def _active_seconds_per_day():
197
+ span = (ACTIVE_END_H - ACTIVE_START_H) % 24
198
+ return (span or 24) * 3600
199
+
200
+
201
+ def _in_active_window(dt_local):
202
+ h = dt_local.hour
203
+ if ACTIVE_START_H == ACTIVE_END_H:
204
+ return True
205
+ if ACTIVE_START_H < ACTIVE_END_H:
206
+ return ACTIVE_START_H <= h < ACTIVE_END_H
207
+ return h >= ACTIVE_START_H or h < ACTIVE_END_H # window wraps midnight
208
+
209
+
210
+ def _seconds_to_window_open(dt_local):
211
+ """Seconds until the active window next opens. 0 if already open."""
212
+ if _in_active_window(dt_local):
213
+ return 0
214
+ nxt = dt_local.replace(hour=ACTIVE_START_H, minute=0, second=0, microsecond=0)
215
+ if nxt <= dt_local:
216
+ nxt += timedelta(days=1)
217
+ return (nxt - dt_local).total_seconds()
218
+
219
+
220
+ def _target_gap(last_ts):
221
+ """Exponentially-distributed target gap, deterministic in `last_ts`.
222
+
223
+ mean = active_seconds_per_day / MAX_PER_24H, so a full day's budget spreads
224
+ naturally across the active window instead of bunching at a floor.
225
+
226
+ Determinism matters: see the CONFIG note. Same last_ts always yields the
227
+ same target, so polling cannot reroll its way down to MIN_GAP_S.
228
+ """
229
+ import hashlib
230
+
231
+ mean = _active_seconds_per_day() / max(MAX_PER_24H, 1)
232
+ digest = hashlib.sha256(last_ts.isoformat().encode("utf-8")).digest()
233
+ # 53 bits -> uniform in [0,1); nudged off the endpoints for the log below.
234
+ u = int.from_bytes(digest[:7], "big") / float(1 << 56)
235
+ u = min(max(u, 1e-9), 1 - 1e-9)
236
+ gap = -mean * math.log(1.0 - u) # inverse-CDF of Exponential(1/mean)
237
+ return max(MIN_GAP_S, min(gap, MAX_GAP_S))
238
+
239
+
240
+ def _gaps(ts):
241
+ return [
242
+ (b - a).total_seconds()
243
+ for a, b in zip(ts, ts[1:])
244
+ if (b - a).total_seconds() >= 0
245
+ ]
246
+
247
+
248
+ def _cv(gaps):
249
+ """Coefficient of variation. None when undefined (need >= 2 gaps)."""
250
+ if len(gaps) < 2:
251
+ return None
252
+ mean = statistics.fmean(gaps)
253
+ if mean <= 0:
254
+ return 0.0
255
+ return statistics.stdev(gaps) / mean
256
+
257
+
258
+ def evaluate(now=None, timestamps=None):
259
+ """Return a decision dict. Pure given its inputs, so it is testable."""
260
+ now = now or datetime.now(timezone.utc)
261
+ ts = timestamps if timestamps is not None else _recent_timestamps()
262
+
263
+ in_1h = [t for t in ts if t >= now - timedelta(hours=1)]
264
+ in_24h = [t for t in ts if t >= now - timedelta(hours=24)]
265
+ in_72h = ts
266
+
267
+ counters = {
268
+ "count_1h": len(in_1h),
269
+ "count_24h": len(in_24h),
270
+ "count_72h": len(in_72h),
271
+ "max_1h": MAX_PER_1H,
272
+ "max_24h": MAX_PER_24H,
273
+ "max_72h": MAX_PER_72H,
274
+ }
275
+
276
+ def decision(action, rc, reason, wait_seconds=0, **extra):
277
+ d = {
278
+ "action": action,
279
+ "rc": rc,
280
+ "reason": reason,
281
+ "wait_seconds": int(wait_seconds),
282
+ "checked_at": now.isoformat(),
283
+ }
284
+ d.update(counters)
285
+ d.update(extra)
286
+ return d
287
+
288
+ # ---- hard ceilings first: these mean "stop", not "wait a bit" ----------
289
+ if len(in_24h) >= MAX_PER_24H:
290
+ return decision("stop", RC_STOP,
291
+ f"24h cap reached ({len(in_24h)}/{MAX_PER_24H})")
292
+ if len(in_72h) >= MAX_PER_72H:
293
+ return decision("stop", RC_STOP,
294
+ f"72h cap reached ({len(in_72h)}/{MAX_PER_72H})")
295
+
296
+ # ---- rolling hour: a wait, since it clears on its own ------------------
297
+ if len(in_1h) >= MAX_PER_1H:
298
+ oldest = min(in_1h)
299
+ wait = (oldest + timedelta(hours=1) - now).total_seconds()
300
+ return decision("wait", RC_WAIT,
301
+ f"1h cap reached ({len(in_1h)}/{MAX_PER_1H})",
302
+ max(wait, 60))
303
+
304
+ # ---- active-hours window ----------------------------------------------
305
+ # Humans do not comment uniformly around the clock. Observed spans were
306
+ # 20-24h/day, which is itself a tell independent of volume.
307
+ now_local = now.astimezone()
308
+ to_open = _seconds_to_window_open(now_local)
309
+ if to_open > 0:
310
+ return decision("wait", RC_WAIT,
311
+ f"outside active window "
312
+ f"{ACTIVE_START_H:02d}:00-{ACTIVE_END_H:02d}:00 local",
313
+ to_open)
314
+
315
+ # ---- sampled inter-action interval (the actual pacing) ----------------
316
+ # Not a flat floor: a per-gap draw from an exponential, so the resulting
317
+ # distribution is continuous instead of piling up at a single value.
318
+ if ts:
319
+ last = max(ts)
320
+ since_last = (now - last).total_seconds()
321
+ target = _target_gap(last)
322
+ if since_last < target:
323
+ return decision("wait", RC_WAIT,
324
+ f"sampled interval not elapsed "
325
+ f"({int(since_last)}s < {int(target)}s target)",
326
+ target - since_last,
327
+ seconds_since_last=int(since_last),
328
+ target_gap_s=int(target))
329
+
330
+ # ---- cadence regularity -----------------------------------------------
331
+ # CRITICAL: both this rule and the spread rule below must be evaluated
332
+ # against the state that WOULD exist if we posted right now, never against
333
+ # history alone. History does not change while we wait, so a rule phrased
334
+ # over past gaps can never be satisfied by waiting: an earlier version of
335
+ # this file compared the last two gaps (120s, 120s -> CV 0.00), told the
336
+ # caller to sleep, and then returned the identical verdict forever. That
337
+ # livelocked the pipeline at 3 actions total and cost 1.3 actions/day
338
+ # instead of the intended ~25. Including the pending gap makes waiting
339
+ # monotonically increase CV, so the rule always resolves itself.
340
+ candidate_gap = (now - max(ts)).total_seconds() if ts else None
341
+ gaps = _gaps(ts)
342
+ if candidate_gap is not None:
343
+ gaps = gaps + [candidate_gap]
344
+ gaps = gaps[-CV_WINDOW:]
345
+ cv = _cv(gaps)
346
+ if cv is not None and cv < CV_FLOOR:
347
+ # Metronomic. Break it with a long randomized pause rather than the floor.
348
+ pause = random.uniform(CV_PAUSE_MIN_S, CV_PAUSE_MAX_S)
349
+ return decision("wait", RC_WAIT,
350
+ f"cadence too regular (CV={cv:.2f} < {CV_FLOOR:.2f} "
351
+ f"over last {len(gaps)} gaps incl. pending)",
352
+ pause, cv=round(cv, 3))
353
+
354
+ # ---- daily spread: do not bunch the day's actions into one window ------
355
+ # Same "as if we posted now" framing: `now` is the effective end of the
356
+ # window, so the spread grows as we wait and the rule cannot livelock.
357
+ if len(in_24h) >= max(3, MAX_PER_24H // 2):
358
+ spread = (now - min(in_24h)).total_seconds()
359
+ if spread < MIN_SPREAD_S:
360
+ wait = MIN_SPREAD_S - spread
361
+ return decision("wait", RC_WAIT,
362
+ f"daily spread too tight ({int(spread/60)}min over "
363
+ f"{len(in_24h)} actions; want >= {MIN_SPREAD_S//3600}h)",
364
+ wait, spread_minutes=int(spread / 60))
365
+
366
+ return decision("allow", RC_ALLOW, "within all pacing limits",
367
+ cv=(round(cv, 3) if cv is not None else None))
368
+
369
+
370
+ def main():
371
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
372
+ ap.add_argument("command", choices=["check", "status"])
373
+ ap.add_argument("--json", action="store_true",
374
+ help="emit the full decision as JSON")
375
+ args = ap.parse_args()
376
+
377
+ try:
378
+ d = evaluate()
379
+ except Exception as exc: # noqa: BLE001 - fail closed on ANY read failure
380
+ d = {
381
+ "action": "stop",
382
+ "rc": RC_STOP,
383
+ "reason": f"pacing state unreadable, failing closed: {exc}",
384
+ "wait_seconds": 0,
385
+ }
386
+
387
+ if args.command == "status":
388
+ print(json.dumps(d, indent=2))
389
+ return 0
390
+
391
+ if args.json:
392
+ print(json.dumps(d))
393
+ else:
394
+ print(f"{d['action'].upper()}: {d['reason']}"
395
+ + (f" (wait {d['wait_seconds']}s)" if d["wait_seconds"] else ""))
396
+ return d["rc"]
397
+
398
+
399
+ if __name__ == "__main__":
400
+ sys.exit(main())
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env python3
2
+ """LinkedIn session canary: detect a dropped session in ~60s instead of ~30min.
3
+
4
+ What problem this solves
5
+ ------------------------
6
+ `detect-gate` only probes the session at the START of a pipeline run. On
7
+ 2026-07-20 it reported "session healthy (feed renders)" at 20:27:31Z, the
8
+ session was killed somewhere around 20:50Z mid-comment, and nothing noticed
9
+ until the next run-linkedin fire at 20:57:12Z. That is a ~30 minute blind
10
+ window in which the pipeline believed it was logged in.
11
+
12
+ This canary closes that window by reading the `li_at` session cookie straight
13
+ out of the ALREADY-RUNNING harness Chrome over CDP. Two properties matter:
14
+
15
+ 1. It costs ZERO LinkedIn traffic. CDP is a local debugging channel, so this
16
+ adds no request footprint to an account that is already flagged. That is
17
+ why we do not simply fetch /feed/ on a timer.
18
+ 2. It reads the LIVE browser, not the on-disk Cookies sqlite file. The
19
+ on-disk store lags badly: on 2026-07-20 the `linkedin` profile's Cookies
20
+ db had an mtime of 2026-07-02 while the live context still held a valid
21
+ li_at. Reading the file would produce false alarms.
22
+
23
+ Conservative by design: if Chrome is down, or CDP does not answer, or the
24
+ cookie read fails, we report `unknown` and do NOTHING. A canary that cries wolf
25
+ gets ignored, and engaging the killswitch is disruptive (it halts nine launchd
26
+ jobs). We only act on a positive, well-formed "Chrome is up, cookies read fine,
27
+ li_at is absent".
28
+
29
+ CLI
30
+ ---
31
+ linkedin_session_watch.py check # report only, never engages
32
+ linkedin_session_watch.py check --engage # engage killswitch if li_at gone
33
+ linkedin_session_watch.py check --json
34
+
35
+ Exit codes:
36
+ 0 li_at present (healthy)
37
+ 1 li_at absent (session dropped)
38
+ 2 unknown / not checkable (Chrome down, CDP silent) - NOT an error
39
+
40
+ Intended use: launchd every 60s with --engage.
41
+ """
42
+
43
+ import argparse
44
+ import json
45
+ import os
46
+ import subprocess
47
+ import sys
48
+ import urllib.request
49
+ from datetime import datetime, timezone
50
+
51
+ CDP_URL = os.environ.get("LINKEDIN_CDP_URL", "http://127.0.0.1:9556")
52
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
53
+ KILLSWITCH = os.path.join(REPO_DIR, "scripts", "linkedin_killswitch.py")
54
+ STATE_PATH = os.path.expanduser(
55
+ "~/.claude/social-autoposter/linkedin.session_canary.json"
56
+ )
57
+ TIMEOUT_S = 8
58
+
59
+
60
+ def _browser_ws_url():
61
+ """Browser-level CDP websocket URL, or None if Chrome is not reachable."""
62
+ try:
63
+ with urllib.request.urlopen(f"{CDP_URL}/json/version", timeout=TIMEOUT_S) as r:
64
+ return json.loads(r.read().decode("utf-8")).get("webSocketDebuggerUrl")
65
+ except Exception:
66
+ return None
67
+
68
+
69
+ def _all_cookies(ws_url):
70
+ """Storage.getCookies at browser scope. Returns list, or None on failure.
71
+
72
+ Uses websocket-client, which is present on the pipeline's /opt/homebrew/bin/python3.
73
+ We deliberately avoid playwright's connect_over_cdp here: this runs every
74
+ minute alongside live pipeline work, and a passive single-command socket is
75
+ far less likely to perturb a run than attaching a full automation client.
76
+ """
77
+ try:
78
+ import websocket # websocket-client
79
+ except ImportError:
80
+ return None
81
+ ws = None
82
+ try:
83
+ # suppress_origin is REQUIRED: websocket-client otherwise sends an
84
+ # Origin header and Chrome rejects the handshake with
85
+ # "403 Rejected an incoming WebSocket connection from the ... origin".
86
+ ws = websocket.create_connection(
87
+ ws_url, timeout=TIMEOUT_S, suppress_origin=True
88
+ )
89
+ ws.send(json.dumps({"id": 1, "method": "Storage.getCookies", "params": {}}))
90
+ # The browser endpoint can interleave events; read until our reply lands.
91
+ for _ in range(20):
92
+ msg = json.loads(ws.recv())
93
+ if msg.get("id") == 1:
94
+ if "error" in msg:
95
+ return None
96
+ return msg.get("result", {}).get("cookies", [])
97
+ return None
98
+ except Exception:
99
+ return None
100
+ finally:
101
+ if ws is not None:
102
+ try:
103
+ ws.close()
104
+ except Exception:
105
+ pass
106
+
107
+
108
+ def check():
109
+ now = datetime.now(timezone.utc).isoformat()
110
+ ws_url = _browser_ws_url()
111
+ if not ws_url:
112
+ return {"status": "unknown", "reason": "harness Chrome not reachable on CDP",
113
+ "checked_at": now}
114
+
115
+ cookies = _all_cookies(ws_url)
116
+ if cookies is None:
117
+ return {"status": "unknown", "reason": "CDP cookie read failed",
118
+ "checked_at": now}
119
+
120
+ li = [c for c in cookies if "linkedin.com" in (c.get("domain") or "")]
121
+ has_li_at = any(c.get("name") == "li_at" for c in li)
122
+ has_li_rm = any(c.get("name") == "li_rm" for c in li)
123
+
124
+ return {
125
+ "status": "healthy" if has_li_at else "dropped",
126
+ "reason": ("li_at present" if has_li_at
127
+ else "li_at absent from live harness Chrome"),
128
+ "linkedin_cookie_count": len(li),
129
+ "has_li_rm": has_li_rm,
130
+ "checked_at": now,
131
+ }
132
+
133
+
134
+ def _killswitch_active():
135
+ try:
136
+ return subprocess.run(
137
+ ["/opt/homebrew/bin/python3", KILLSWITCH, "check"],
138
+ capture_output=True, timeout=30,
139
+ ).returncode != 0
140
+ except Exception:
141
+ return True # assume active; never double-engage on uncertainty
142
+
143
+
144
+ def engage(detail):
145
+ """Engage the killswitch. Idempotent upstream: the FIRST signal wins."""
146
+ if _killswitch_active():
147
+ return {"engaged": False, "note": "killswitch already active"}
148
+ try:
149
+ p = subprocess.run(
150
+ ["/opt/homebrew/bin/python3", KILLSWITCH, "engage",
151
+ "--signal", "li_at_cleared", "--detail", detail],
152
+ capture_output=True, text=True, timeout=120,
153
+ )
154
+ return {"engaged": p.returncode == 0,
155
+ "stdout": (p.stdout or "").strip()[:300],
156
+ "stderr": (p.stderr or "").strip()[:300]}
157
+ except Exception as exc: # noqa: BLE001
158
+ return {"engaged": False, "error": str(exc)[:200]}
159
+
160
+
161
+ def _write_state(result):
162
+ try:
163
+ os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
164
+ with open(STATE_PATH, "w", encoding="utf-8") as fh:
165
+ json.dump(result, fh, indent=2)
166
+ except OSError:
167
+ pass
168
+
169
+
170
+ def main():
171
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
172
+ ap.add_argument("command", choices=["check"])
173
+ ap.add_argument("--engage", action="store_true",
174
+ help="engage the killswitch when li_at is absent")
175
+ ap.add_argument("--json", action="store_true")
176
+ args = ap.parse_args()
177
+
178
+ result = check()
179
+
180
+ if result["status"] == "dropped" and args.engage:
181
+ result["killswitch"] = engage(
182
+ "session canary: li_at absent from live harness Chrome"
183
+ )
184
+
185
+ _write_state(result)
186
+
187
+ if args.json:
188
+ print(json.dumps(result))
189
+ else:
190
+ print(f"{result['status'].upper()}: {result['reason']}")
191
+
192
+ return {"healthy": 0, "dropped": 1, "unknown": 2}[result["status"]]
193
+
194
+
195
+ if __name__ == "__main__":
196
+ sys.exit(main())
@@ -2879,7 +2879,7 @@ def main():
2879
2879
  parser.add_argument("--batch-id", dest="batch_id", default=None,
2880
2880
  help="Cycle-level batch_id (e.g. rdcycle-YYYYMMDD-HHMMSS). Used by "
2881
2881
  "--phase phase0 / --phase salvage / --phase discover to attribute "
2882
- "rows in reddit_candidates and reddit_batches. Required for "
2882
+ "rows in reddit_candidates. Required for "
2883
2883
  "phase0 and salvage; optional for discover (defaults to a "
2884
2884
  "per-discover synthetic id).")
2885
2885
  args = parser.parse_args()
@@ -99,7 +99,22 @@ def _reddit_page(pw):
99
99
  try:
100
100
  browser = pw.chromium.connect_over_cdp(ws)
101
101
  ctx = browser.contexts[0] if browser.contexts else browser.new_context()
102
- page = ctx.new_page()
102
+ # Reuse an existing reddit tab instead of new_page(). new_page() opens
103
+ # a fresh tab on the reddit homepage and STEALS OS FOCUS every call —
104
+ # this ran ~per-strike and was the reddit focus-pop the other reddit
105
+ # helpers (reddit_browser / reddit_browser_fetch) already fixed but
106
+ # this 2026-07-17 ban-check never did. Navigating a background tab does
107
+ # not pop. Prefer a tab already on reddit.com; else pages[0]; else
108
+ # create one. Left OPEN for reuse (cleanup_harness_tabs trims to one).
109
+ page = None
110
+ for pg in ctx.pages:
111
+ if "reddit.com" in (pg.url or "") and "login" not in (pg.url or ""):
112
+ page = pg
113
+ break
114
+ if page is None and ctx.pages:
115
+ page = ctx.pages[0]
116
+ if page is None:
117
+ page = ctx.new_page()
103
118
  page.set_default_timeout(20000)
104
119
  return browser, page
105
120
  except Exception:
@@ -0,0 +1,134 @@
1
+ import os, re, subprocess, hashlib, itertools, json
2
+ import psycopg2
3
+ from collections import defaultdict
4
+
5
+ REM = os.path.expanduser("~/social-autoposter/mixer/remotion")
6
+ PUB = os.path.join(REM, "public", "mixer")
7
+ FFPROBE = "ffprobe"
8
+ os.environ["PATH"] = "/opt/homebrew/Cellar/ffmpeg/8.1.1/bin:" + os.environ.get("PATH","")
9
+
10
+ def dburl():
11
+ with open(os.path.expanduser("~/social-autoposter/.env")) as f:
12
+ for line in f:
13
+ if line.startswith("DATABASE_URL="):
14
+ return line.split("=",1)[1].strip()
15
+ raise SystemExit("no DATABASE_URL")
16
+
17
+ def probe_dur(p):
18
+ try:
19
+ out = subprocess.check_output([FFPROBE,"-v","error","-show_entries","format=duration","-of","csv=p=0",p]).decode().strip()
20
+ return float(out)
21
+ except Exception:
22
+ return None
23
+
24
+ def md5(p):
25
+ h=hashlib.md5()
26
+ with open(p,"rb") as f:
27
+ for chunk in iter(lambda:f.read(1<<20), b""):
28
+ h.update(chunk)
29
+ return h.hexdigest()
30
+
31
+ # 1. all tlh-*.mp4 slot files, their duration + content md5
32
+ slots={}
33
+ for fn in os.listdir(PUB):
34
+ if fn.startswith("tlh-") and fn.endswith(".mp4"):
35
+ p=os.path.join(PUB,fn)
36
+ d=probe_dur(p)
37
+ slots[fn]={"dur":d,"md5":md5(p)}
38
+
39
+ # content class = md5. Map filename -> class
40
+ fn2class={fn:info["md5"] for fn,info in slots.items()}
41
+ # which classes are ~2.0s (target 2.0 slots). Consider a file 2.0s if 1.95<=dur<=2.05
42
+ two_sec_classes=set()
43
+ class_dur=defaultdict(list)
44
+ for fn,info in slots.items():
45
+ if info["dur"] is not None:
46
+ class_dur[info["md5"]].append(info["dur"])
47
+ for cls,durs in class_dur.items():
48
+ avg=sum(durs)/len(durs)
49
+ if 1.95<=avg<=2.05:
50
+ two_sec_classes.add(cls)
51
+
52
+ # 2. DB: all lesson-% rows, source_clips + post_number
53
+ conn=psycopg2.connect(dburl())
54
+ cur=conn.cursor()
55
+ cur.execute("""SELECT post_number, variant_id, source_clips, metadata->>'theme_angle'
56
+ FROM media_posts WHERE variant_id LIKE 'lesson-%' AND source_clips IS NOT NULL
57
+ ORDER BY post_number""")
58
+ rows=cur.fetchall()
59
+
60
+ # map each row -> set of content classes (from src basenames), record post ordering
61
+ variant_classes=[] # (post_number, variant_id, frozenset(classes))
62
+ class_last_post=defaultdict(lambda:-1)
63
+ for pn,vid,sc,angle in rows:
64
+ if isinstance(sc,str):
65
+ sc=json.loads(sc)
66
+ classes=set()
67
+ for c in sc:
68
+ src=c.get("src","")
69
+ base=os.path.basename(src)
70
+ cls=fn2class.get(base)
71
+ if cls:
72
+ classes.add(cls)
73
+ variant_classes.append((pn,vid,frozenset(classes)))
74
+ for cls in classes:
75
+ if pn>class_last_post[cls]:
76
+ class_last_post[cls]=pn
77
+
78
+ # recent organic renders = last 5 lesson rows by post_number
79
+ recent5=set()
80
+ for pn,vid,cls in sorted(variant_classes,key=lambda x:-x[0])[:5]:
81
+ recent5|=set(cls)
82
+
83
+ # candidate 2.0s classes, ranked by staleness (lowest last_post first)
84
+ cands=[(class_last_post[c],c) for c in two_sec_classes]
85
+ cands.sort() # stalest first
86
+ print("== 2.0s content classes, stalest first (last_post, class, example_files) ==")
87
+ class_files=defaultdict(list)
88
+ for fn,cls in fn2class.items():
89
+ class_files[cls].append(fn)
90
+ for lp,c in cands[:20]:
91
+ print(f" last_post={lp:>4} {c[:12]} files={sorted(class_files[c])[:4]}")
92
+
93
+ # no-3+-co-occurrence: chosen 4-set shares <=2 classes with ANY prior variant
94
+ def ok_set(fourset):
95
+ fs=set(fourset)
96
+ for pn,vid,cls in variant_classes:
97
+ if len(fs & set(cls))>=3:
98
+ return False
99
+ return True
100
+
101
+ # not in recent5
102
+ pool=[c for lp,c in cands if c not in recent5]
103
+ print(f"\n== pool (stale, not in last-5) size={len(pool)} ==")
104
+
105
+ # greedily search: take combinations of the stalest, prefer overall stalest sum
106
+ chosen=None
107
+ # limit search to stalest ~14 for tractability
108
+ search=pool[:14]
109
+ best=None
110
+ for combo in itertools.combinations(search,4):
111
+ if ok_set(combo):
112
+ score=sum(class_last_post[c] for c in combo)
113
+ if best is None or score<best[0]:
114
+ best=(score,combo)
115
+ if best:
116
+ chosen=best[1]
117
+ print("\n== CHOSEN 4-set (min staleness score, no 3+ co-occurrence) ==")
118
+ for c in chosen:
119
+ print(f" last_post={class_last_post[c]:>4} {c[:12]} pick_file={sorted(class_files[c])[0]}")
120
+ # verify max pairwise co-occurrence
121
+ maxco=0
122
+ for pn,vid,cls in variant_classes:
123
+ ov=len(set(chosen)&set(cls))
124
+ maxco=max(maxco,ov)
125
+ print(f" max overlap with any prior variant = {maxco}")
126
+ print(" RESULT_FILES="+",".join(sorted(class_files[c])[0] for c in chosen))
127
+ else:
128
+ print("NO VALID SET FOUND")
129
+
130
+ # also report existing max lesson number
131
+ cur.execute("SELECT variant_id FROM media_posts WHERE variant_id ~ '^lesson-[0-9]+$'")
132
+ nums=[int(r[0].split('-')[1]) for r in cur.fetchall()]
133
+ print("\nDB max lesson num =", max(nums) if nums else None)
134
+ cur.close(); conn.close()
@@ -278,8 +278,46 @@ RESET_COUNT=$(li_reset_processing 2)
278
278
 
279
279
  PENDING_COUNT=$(li_reply_count pending)
280
280
 
281
+ # ---- PACING PREFLIGHT (the enforceable half of the pacing gate) -----------
282
+ # The per-reply Step 3b gate inside the Phase B prompt is the fine-grained
283
+ # control, but it depends on the model obeying an instruction. This preflight
284
+ # is the hard stop: when a 24h/72h ceiling is already blown there is nothing
285
+ # Phase B could legally post, so we skip the phase before paying for browser
286
+ # bootstrap, the pending pull and a Claude run.
287
+ #
288
+ # rc=78 ("stop") zeroes PENDING_COUNT rather than exiting, deliberately: an
289
+ # early exit here would skip the run summary and the dashboard metric line at
290
+ # the bottom of this script, and a pipeline that goes quiet WITHOUT reporting
291
+ # is precisely how the 2026-07-20 outage stayed invisible for nine days.
292
+ # rc=75 ("wait") still enters the phase; the per-reply gate sleeps the short
293
+ # remainder itself.
294
+ _LI_PACE_RC=0
295
+ _LI_PACE_JSON="$("$PY_BIN" "$REPO_DIR/scripts/linkedin_pacing.py" check --json 2>&1)" || _LI_PACE_RC=$?
296
+ _LI_PACE_WAIT=$(printf '%s' "$_LI_PACE_JSON" | "$PY_BIN" -c \
297
+ 'import json,sys
298
+ try: print(int(json.load(sys.stdin).get("wait_seconds", 0)))
299
+ except Exception: print(0)' 2>/dev/null || echo 0)
300
+ log "PACING: $_LI_PACE_JSON"
301
+
302
+ # Skip Phase B when a ceiling is blown (rc=78) OR when the next slot is far off
303
+ # (rc=75 with a long wait). The long-wait case is mostly the overnight
304
+ # active-hours window: without this we would spin up a full Claude session that
305
+ # is forbidden from posting anything for the next several hours. Short waits
306
+ # (< 15 min) still enter the phase, since drafting takes time anyway and the
307
+ # per-reply Step 3b gate can sleep the small remainder.
308
+ if [ "$PENDING_COUNT" -ne 0 ] && { [ "$_LI_PACE_RC" -eq 78 ] || \
309
+ { [ "$_LI_PACE_RC" -eq 75 ] && [ "${_LI_PACE_WAIT:-0}" -gt 900 ]; }; }; then
310
+ log "PACING: not postable now (rc=$_LI_PACE_RC, next slot in ${_LI_PACE_WAIT}s); skipping Phase B ($PENDING_COUNT rows stay pending)"
311
+ PENDING_COUNT=0
312
+ LI_PACED_OUT=1
313
+ fi
314
+
281
315
  if [ "$PENDING_COUNT" -eq 0 ]; then
282
- log "Phase B: No pending LinkedIn replies. Done!"
316
+ if [ "${LI_PACED_OUT:-0}" -eq 1 ]; then
317
+ log "Phase B: held back by the pacing gate, not by an empty queue."
318
+ else
319
+ log "Phase B: No pending LinkedIn replies. Done!"
320
+ fi
283
321
  else
284
322
  log "Phase B: $PENDING_COUNT pending LinkedIn replies to process"
285
323
 
@@ -467,6 +505,24 @@ MANDATORY reply flow for every item:
467
505
  mode ($PICKED_MODE=invent) craft a NEW snake_case style name not in the
468
506
  curated block above and pass it as the [engagement_style] arg in Step 5.
469
507
  Professional but casual. NEVER em dashes. Match parent post language.
508
+ Step 3b: PACING GATE - MANDATORY, run this IMMEDIATELY BEFORE every single
509
+ post in Step 4. Never post two replies without a fresh check in
510
+ between; never batch several posts behind one check.
511
+ python3 $REPO_DIR/scripts/linkedin_pacing.py check --json
512
+ Read the JSON "action" field and obey it exactly:
513
+ "allow" (exit 0) -> proceed to Step 4 for THIS reply only.
514
+ "wait" (exit 75) -> sleep the returned "wait_seconds", then
515
+ re-run the check. If it still says wait after
516
+ 3 attempts, LEAVE the row pending (do NOT mark
517
+ skipped) and move to the next reply; the next
518
+ cycle will pick it up.
519
+ "stop" (exit 78) -> a 24h/72h ceiling is blown. STOP POSTING
520
+ ENTIRELY for this run. Leave all remaining
521
+ rows pending and finish the run cleanly.
522
+ Do NOT try to out-think, average, or "catch up on" this gate; it is
523
+ the control that keeps our comment cadence from looking like a
524
+ metronome. On 2026-07-20 an unpaced run put 23 comments out in 22
525
+ minutes at 60s +/- 29s and the session was killed that evening.
470
526
  Step 4: post reply (OAuth API first, browser fallback)
471
527
  Step 5: python3 reply_db.py replied ID "text" [url] [engagement_style] [is_recommendation] <- mark AFTER success. engagement_style is the style name you applied (in USE mode the assigned '${PICKED_STYLE}'). is_recommendation="1" only when you mentioned a project (Tier 2/3).
472
528
  If Step 5 fails, the item stays 'processing' and will be reset to 'pending' on the next run.
@@ -106,6 +106,30 @@ export SA_CYCLE_ID="$BATCH_ID"
106
106
 
107
107
  echo "=== LinkedIn Post Run: $(date) (batch=$BATCH_ID) ===" | tee "$LOG_FILE"
108
108
 
109
+ # ---- PACING GATE (2026-07-30) --------------------------------------------
110
+ # This pipeline is the LARGER of the two LinkedIn write paths: it logged 67-74
111
+ # actions/day via log_post.py in the week before the 2026-07-20 session kill,
112
+ # against ~23/day from engage-linkedin.sh. Gating only that other script would
113
+ # have left ~75% of our LinkedIn footprint unpaced.
114
+ #
115
+ # Placed BEFORE Phase A on purpose. Phase B posts exactly ONE comment per fire,
116
+ # so a single per-run check is sufficient, and bailing here also skips Phase A
117
+ # discovery (~$10-15 of Claude spend) rather than paying for candidates we are
118
+ # not allowed to act on.
119
+ #
120
+ # Any non-allow verdict skips the fire outright: this job is on a 15-minute
121
+ # launchd cadence, so the next fire re-checks shortly. We deliberately do not
122
+ # sleep inside the run and hold the browser lock while waiting.
123
+ if [ "$LINKEDIN_BACKEND" = "browser" ]; then
124
+ _LI_PACE_RC=0
125
+ _LI_PACE_OUT="$(python3 "$REPO_DIR/scripts/linkedin_pacing.py" check 2>&1)" || _LI_PACE_RC=$?
126
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PACING: $_LI_PACE_OUT" | tee -a "$LOG_FILE"
127
+ if [ "$_LI_PACE_RC" -ne 0 ]; then
128
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] PACING: skipping this fire (rc=$_LI_PACE_RC); next fire re-checks in ~15min" | tee -a "$LOG_FILE"
129
+ exit 0
130
+ fi
131
+ fi
132
+
109
133
  # 2026-05-01: lock policy was changed from "hold for the entire run" to
110
134
  # "hold only while a Claude phase is actively driving the browser". The old
111
135
  # policy meant a single 25-45min cycle held linkedin-browser exclusively for