@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
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """browser_mutex.py — THE per-platform browser-session mutex (2026-07-14).
3
+
4
+ A faithful, parameterized extraction of scripts/twitter_browser.py's
5
+ battle-hardened file mutex, so every platform driver shares ONE implementation
6
+ instead of three divergent ones (reddit_browser.py's old copy still had the
7
+ check-then-write claim race AND the dead-holder starvation twitter fixed on
8
+ 2026-06-16). The twitter semantics are preserved bit-for-bit: same lockfile
9
+ JSON shape ({session_id, timestamp, role}), same reclaim ladder, same
10
+ "locked by session" give-up string downstream parsers grep for.
11
+
12
+ This mutex serializes the PYTHON drivers of one harness Chrome. It deliberately
13
+ does NOT share a path with the shell pipeline lock (skill/lock.sh's
14
+ /tmp/social-autoposter-<name>.lock dirs): pipelines hold that lock around whole
15
+ phases while their child python ops take THIS one per op — merging the two onto
16
+ one path would deadlock that nesting (or need ancestor-walk inheritance in
17
+ every acquire). Unification here means one LIBRARY, not one lock.
18
+
19
+ Reclaim ladder (a holder we can PROVE dead is taken immediately, so a crashed
20
+ peer can never starve the fleet):
21
+ 1. holder == us -> re-entrant; refresh and proceed.
22
+ 1b. holder == $S4L_LOCK_OWNER (live) -> batch inherit: a poster parent holds
23
+ the lock across a whole approved batch; its child reply subprocesses
24
+ refresh instead of contending, and leave release to the parent.
25
+ 2. UUID holder, pid gone -> stale legacy (Claude session) lock, reclaim.
26
+ 3. python:PID, pid gone -> dead peer, reclaim.
27
+ 4. age >= expiry -> failsafe (role "post" holders get the shorter
28
+ post_lock_expiry so a hung poster self-clears).
29
+ 5. live UUID holder -> inherit (parent Claude session).
30
+ 5b. we are role "post", holder is a LIVE lower-priority python peer ->
31
+ PREEMPT it (SIGTERM, grace, SIGKILL) and claim: posting is the scarce
32
+ user-initiated action; the aborted scan re-runs next tick.
33
+ 6. live python peer -> wait, then give up after wait_max with the
34
+ structured "locked by session" error.
35
+
36
+ Do NOT "simplify" by letting shell pipelines rm -f the lockfile: that blind rm
37
+ deleted LIVE peers' locks (defect b, removed 2026-06-16). See
38
+ docs/twitter_browser_lock.md for the incident history behind every branch.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import os
45
+ import re
46
+ import signal
47
+ import subprocess
48
+ import sys
49
+ import time
50
+
51
+ _UUID_RE = re.compile(
52
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I
53
+ )
54
+
55
+
56
+ class BrowserMutex:
57
+ def __init__(
58
+ self,
59
+ lock_file: str,
60
+ label: str,
61
+ lock_expiry: int = 300,
62
+ post_lock_expiry: int = 180,
63
+ wait_max: int = 45,
64
+ poll_interval: int = 2,
65
+ preempt_kill_wait: int = 5,
66
+ role: str | None = None,
67
+ on_touch=None,
68
+ ):
69
+ """label is the human prefix in error strings ("Twitter browser",
70
+ "Reddit browser") — downstream parsers grep "locked by session", keep
71
+ the shape. on_touch (optional callable) runs after every successful
72
+ acquire/refresh (reddit bumps its bash lease there); it must never
73
+ raise consequences: exceptions are swallowed."""
74
+ self.lock_file = os.path.expanduser(lock_file)
75
+ self.label = label
76
+ self.lock_expiry = lock_expiry
77
+ self.post_lock_expiry = post_lock_expiry
78
+ self.wait_max = wait_max
79
+ self.poll_interval = poll_interval
80
+ self.preempt_kill_wait = preempt_kill_wait
81
+ self.role = (role or os.environ.get("S4L_LOCK_ROLE") or "scan").strip() or "scan"
82
+ self.on_touch = on_touch
83
+ self.session_id = f"python:{os.getpid()}"
84
+ self.inherited = False
85
+ self.acquired_at: float | None = None
86
+
87
+ # ---- liveness probes ----------------------------------------------------
88
+ @staticmethod
89
+ def _is_uuid_holder_alive(holder: str) -> bool:
90
+ if not holder:
91
+ return False
92
+ try:
93
+ return (
94
+ subprocess.run(
95
+ ["pgrep", "-f", f"claude.*--session-id {holder}"],
96
+ stdout=subprocess.DEVNULL,
97
+ stderr=subprocess.DEVNULL,
98
+ timeout=2,
99
+ ).returncode
100
+ == 0
101
+ )
102
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
103
+ return True # err on the side of NOT stealing
104
+
105
+ @staticmethod
106
+ def _is_python_holder_alive(holder: str) -> bool:
107
+ if not holder.startswith("python:"):
108
+ return True # not a python holder; this probe makes no claim
109
+ try:
110
+ pid = int(holder.split(":", 1)[1])
111
+ except (ValueError, IndexError):
112
+ return True
113
+ try:
114
+ os.kill(pid, 0)
115
+ return True
116
+ except ProcessLookupError:
117
+ return False
118
+ except PermissionError:
119
+ return True
120
+ except OSError:
121
+ return True
122
+
123
+ # ---- claim / preempt ----------------------------------------------------
124
+ def _try_take(self) -> bool:
125
+ """O_CREAT|O_EXCL makes "is it free? then take it" one syscall, so two
126
+ cold-start acquirers can't both win (defect c)."""
127
+ try:
128
+ fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
129
+ except FileExistsError:
130
+ return False
131
+ except OSError:
132
+ return False
133
+ try:
134
+ os.write(fd, json.dumps(
135
+ {"session_id": self.session_id, "timestamp": int(time.time()), "role": self.role}
136
+ ).encode())
137
+ finally:
138
+ os.close(fd)
139
+ return True
140
+
141
+ def _preempt(self, pid: int) -> bool:
142
+ try:
143
+ os.kill(pid, signal.SIGTERM)
144
+ except ProcessLookupError:
145
+ return True
146
+ except OSError:
147
+ return False
148
+ deadline = time.time() + self.preempt_kill_wait
149
+ while time.time() < deadline:
150
+ try:
151
+ os.kill(pid, 0)
152
+ except OSError:
153
+ return True
154
+ time.sleep(0.2)
155
+ try:
156
+ os.kill(pid, signal.SIGKILL)
157
+ except OSError:
158
+ pass
159
+ try:
160
+ os.kill(pid, 0)
161
+ except OSError:
162
+ return True
163
+ return False
164
+
165
+ def _touch(self):
166
+ if self.on_touch:
167
+ try:
168
+ self.on_touch()
169
+ except Exception:
170
+ pass
171
+
172
+ # ---- public API -----------------------------------------------------------
173
+ def acquire(self):
174
+ deadline = time.time() + self.wait_max
175
+ try:
176
+ os.makedirs(os.path.dirname(self.lock_file), exist_ok=True)
177
+ except OSError:
178
+ pass
179
+ while True:
180
+ if not os.path.exists(self.lock_file):
181
+ if self._try_take():
182
+ break
183
+ if time.time() >= deadline:
184
+ print(json.dumps({
185
+ "success": False,
186
+ "error": f"{self.label} lock contended on create; waited {self.wait_max}s, giving up."
187
+ }))
188
+ sys.exit(1)
189
+ time.sleep(self.poll_interval)
190
+ continue
191
+ try:
192
+ with open(self.lock_file) as f:
193
+ lock = json.load(f)
194
+ except (json.JSONDecodeError, OSError):
195
+ if self._try_take():
196
+ break
197
+ if time.time() >= deadline:
198
+ print(json.dumps({
199
+ "success": False,
200
+ "error": f"{self.label} lock unreadable; waited {self.wait_max}s, giving up."
201
+ }))
202
+ sys.exit(1)
203
+ time.sleep(self.poll_interval)
204
+ continue
205
+ age = time.time() - lock.get("timestamp", 0)
206
+ holder = lock.get("session_id", "")
207
+ holder_role = lock.get("role", "scan") # legacy locks (no role) = preemptable
208
+
209
+ # 1. Re-entrant.
210
+ if holder == self.session_id and not self.inherited:
211
+ self.refresh()
212
+ break
213
+
214
+ # 1b. Batch-owner inherit (posting).
215
+ batch_owner = os.environ.get("S4L_LOCK_OWNER") or ""
216
+ if holder and holder == batch_owner and self._is_python_holder_alive(holder):
217
+ self.session_id = holder
218
+ self.inherited = True
219
+ self.refresh()
220
+ print(f"[browser_lock] inherited batch owner={holder} "
221
+ f"role={holder_role} -> pid={os.getpid()}", file=sys.stderr)
222
+ break
223
+
224
+ # 2-4. Reclaim provably dead/expired holders.
225
+ reclaim_reason = ""
226
+ if _UUID_RE.match(holder or "") and not self._is_uuid_holder_alive(holder):
227
+ reclaim_reason = "dead_uuid"
228
+ elif holder.startswith("python:") and not self._is_python_holder_alive(holder):
229
+ reclaim_reason = "dead_python"
230
+ elif age >= (self.post_lock_expiry if holder_role == "post" else self.lock_expiry):
231
+ reclaim_reason = "expired"
232
+ if reclaim_reason:
233
+ try:
234
+ os.remove(self.lock_file)
235
+ except OSError:
236
+ pass
237
+ if self._try_take():
238
+ print(f"[browser_lock] reclaimed holder={holder or '<none>'} "
239
+ f"reason={reclaim_reason} age={int(age)}s -> pid={os.getpid()}",
240
+ file=sys.stderr)
241
+ break
242
+ time.sleep(self.poll_interval)
243
+ continue
244
+
245
+ # 5. Live UUID holder = parent Claude session -> inherit.
246
+ if _UUID_RE.match(holder or ""):
247
+ self.session_id = holder
248
+ self.inherited = True
249
+ break
250
+
251
+ # 5b. POSTING PRIORITY: preempt a live lower-priority python peer.
252
+ if (
253
+ self.role == "post"
254
+ and holder.startswith("python:")
255
+ and holder_role != "post"
256
+ and self._is_python_holder_alive(holder)
257
+ ):
258
+ try:
259
+ victim_pid = int(holder.split(":", 1)[1])
260
+ except (ValueError, IndexError):
261
+ victim_pid = 0
262
+ if victim_pid and self._preempt(victim_pid):
263
+ try:
264
+ os.remove(self.lock_file)
265
+ except OSError:
266
+ pass
267
+ if self._try_take():
268
+ print(
269
+ f"[browser_lock] post preempted holder={holder} "
270
+ f"role={holder_role} age={int(age)}s -> pid={os.getpid()}",
271
+ file=sys.stderr,
272
+ )
273
+ break
274
+ time.sleep(self.poll_interval)
275
+ continue
276
+
277
+ # 6. Live python peer: wait, then give up (real contention).
278
+ if time.time() >= deadline:
279
+ print(json.dumps({
280
+ "success": False,
281
+ "error": f"{self.label} locked by session {holder} ({int(age)}s, peer alive); waited {self.wait_max}s, giving up."
282
+ }))
283
+ sys.exit(1)
284
+ time.sleep(self.poll_interval)
285
+ continue
286
+ self.acquired_at = time.time()
287
+ self._touch()
288
+
289
+ def refresh(self):
290
+ try:
291
+ with open(self.lock_file, "w") as f:
292
+ json.dump({"session_id": self.session_id, "timestamp": int(time.time()), "role": self.role}, f)
293
+ except OSError:
294
+ pass
295
+ self._touch()
296
+
297
+ def release(self):
298
+ """Inherited locks are the PARENT'S to release; never clobber them."""
299
+ if self.inherited:
300
+ return
301
+ try:
302
+ if os.path.exists(self.lock_file):
303
+ with open(self.lock_file) as f:
304
+ lock = json.load(f)
305
+ if lock.get("session_id") == self.session_id:
306
+ os.remove(self.lock_file)
307
+ if self.acquired_at:
308
+ held = time.time() - self.acquired_at
309
+ if held >= 5:
310
+ print(
311
+ f"[browser-lock] held {held:.0f}s "
312
+ f"(role={self.role}, pid={os.getpid()})",
313
+ file=sys.stderr,
314
+ )
315
+ except (json.JSONDecodeError, OSError):
316
+ pass
@@ -71,6 +71,11 @@ TAG_TO_TYPE = {
71
71
  # twitter_gen_links.py's Phase 2b-gen step, which already tolerates the
72
72
  # queue worker's cadence — see scripts/link_tail.py).
73
73
  "twitter-link-tail": "twitter-link-tail",
74
+ # Reddit draft phase (queue-native since 2026-07-14): post_reddit.py's
75
+ # draft turn became pure text->JSON (thread content is pre-fetched in
76
+ # Python and inlined; no Bash tools), mirroring the twitter Phase 2b
77
+ # tool-free conversion, so it rides the same universal worker.
78
+ "post-reddit-draft": "reddit-draft",
74
79
  }
75
80
 
76
81
  # queue type -> (activity state, label) the menu bar shows while the job is in
@@ -83,6 +88,10 @@ TYPE_TO_ACTIVITY = {
83
88
  "invent-topic": ("learning", "new topic"),
84
89
  "invent-queries": ("learning", "new queries"),
85
90
  "twitter-link-tail": ("drafting", "link bridge"),
91
+ # Same bare "draft" label as twitter-prep ON PURPOSE: the menu bar title
92
+ # must stay concise (user rule 2026-07-14); the platform shows on the
93
+ # review card itself, not in the tray.
94
+ "reddit-draft": ("drafting", "draft"),
86
95
  }
87
96
 
88
97
  # queue type -> execution notes PREPENDED to the prompt sidecar at claim time.
@@ -114,6 +123,18 @@ TYPE_TO_WORKER_NOTES = {
114
123
  "and not wrapped in any other keys. The caller unwraps a plain string "
115
124
  "result directly; wrapping it in an object will corrupt the reply."
116
125
  ),
126
+ "reddit-draft": (
127
+ "WORKER EXECUTION NOTES (queue metadata; follow while executing the "
128
+ "prompt below): every Reddit thread's content is already inlined in "
129
+ "the prompt — do NOT fetch anything, do NOT run searches, no tools "
130
+ "are needed. Apply the prompt's SELECTION GATE to each candidate, "
131
+ "draft comments for the ones that pass, and submit ONE result object "
132
+ "matching the schema: {\"posts\": [...], \"rejects\": [...]}. A "
133
+ "candidate that fails the gate is simply absent from posts (add a "
134
+ "rejects entry only for the structural false-positive cases the "
135
+ "prompt describes). Batches are small; draft them in one pass and "
136
+ "submit promptly."
137
+ ),
117
138
  }
118
139
 
119
140
 
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """config.py — THE config.json loader. One repo-root resolution, one parser.
3
+
4
+ Before this module (2026-07-14), ~120 call sites each rolled their own
5
+ `json.load(open(.../config.json))` with at least nine different repo-root
6
+ variable conventions (REPO_DIR, REPO_ROOT, ROOT, _REPO_ROOT, ...), and most
7
+ hardcoded `~/social-autoposter`. That hardcode class runs code OUTSIDE the
8
+ managed package on customer boxes (unreachable by auto-update) and silently
9
+ no-ops where the directory doesn't exist — the exact bug that broke session
10
+ restore and tab cleanup on customer installs (S4L-4H triage 2026-07-12).
11
+
12
+ Usage:
13
+ from config import repo_dir, config_path, load_config, get
14
+
15
+ cfg = load_config() # dict, cached
16
+ projects = get("projects", []) # top-level key
17
+ handle = get("accounts.twitter.handle") # dotted path
18
+ cfg = load_config(fresh=True) # bypass cache (long-lived procs)
19
+
20
+ Resolution order for the repo root (single source of truth):
21
+ 1. $S4L_REPO_DIR — set by managed-install launchd plists and the MCP
22
+ server, points at ~/.social-autoposter-mcp/repo/package
23
+ 2. this file's parent — scripts/ lives one level under the repo root, so a
24
+ direct checkout resolves to itself without any env
25
+ 3. ~/social-autoposter — legacy operator-box fallback
26
+
27
+ $S4L_CONFIG_PATH overrides the config file location outright (some installs
28
+ pin the operator config, e.g. ~/s4l/config.json, independent of the code dir).
29
+
30
+ Do NOT add write helpers here. Config writes go through the MCP server's
31
+ project_config tool (it owns validation and the state snapshot); pipeline
32
+ scripts are readers.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import os
39
+
40
+ _CACHE: dict | None = None
41
+ _CACHE_PATH: str | None = None
42
+ _CACHE_MTIME: float | None = None
43
+
44
+
45
+ def repo_dir() -> str:
46
+ env = os.environ.get("S4L_REPO_DIR")
47
+ if env:
48
+ return os.path.expanduser(env)
49
+ here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
50
+ if os.path.exists(os.path.join(here, "config.json")):
51
+ return here
52
+ return os.path.expanduser("~/social-autoposter")
53
+
54
+
55
+ def config_path() -> str:
56
+ env = os.environ.get("S4L_CONFIG_PATH")
57
+ if env:
58
+ p = os.path.expanduser(env)
59
+ else:
60
+ sp = state_config_path()
61
+ p = sp if os.path.exists(sp) else os.path.join(repo_dir(), "config.json")
62
+ # Resolve symlinks to the real target. os.replace()-style atomic writers
63
+ # otherwise REPLACE a symlink with a plain file instead of writing through
64
+ # it, which is exactly how the operator Mac's config split in two on
65
+ # 2026-07-11 and again on 2026-07-13. realpath of a plain file is itself,
66
+ # so customer installs are unaffected.
67
+ return os.path.realpath(p) if os.path.exists(p) else p
68
+
69
+
70
+ def state_config_path() -> str:
71
+ """Canonical home of config.json (2026-07-13): the state dir, which no
72
+ package update, re-materialize, or plist regeneration ever touches. The
73
+ MCP server migrates a legacy $S4L_REPO_DIR/config.json here on boot and
74
+ leaves a symlink behind for direct-path readers; config_path() prefers
75
+ this file whenever it exists."""
76
+ state_dir = os.environ.get("S4L_STATE_DIR") or "~/.social-autoposter-mcp"
77
+ return os.path.join(os.path.expanduser(state_dir), "config.json")
78
+
79
+
80
+ def load_config(fresh: bool = False) -> dict:
81
+ """Parsed config.json. Cached per (path, mtime); pass fresh=True to force
82
+ a re-read regardless (e.g. long-lived processes reacting to edits)."""
83
+ global _CACHE, _CACHE_PATH, _CACHE_MTIME
84
+ path = config_path()
85
+ try:
86
+ mtime = os.path.getmtime(path)
87
+ except OSError:
88
+ return {}
89
+ if not fresh and _CACHE is not None and _CACHE_PATH == path and _CACHE_MTIME == mtime:
90
+ return _CACHE
91
+ try:
92
+ with open(path) as f:
93
+ _CACHE = json.load(f)
94
+ except (OSError, json.JSONDecodeError):
95
+ return _CACHE if (_CACHE is not None and _CACHE_PATH == path) else {}
96
+ _CACHE_PATH = path
97
+ _CACHE_MTIME = mtime
98
+ return _CACHE
99
+
100
+
101
+ def get(path: str, default=None):
102
+ """Dotted-path lookup into config.json: get("accounts.twitter.handle").
103
+ A single-segment path reads a top-level key. Lists are not traversed;
104
+ fetch the list and iterate at the call site."""
105
+ node = load_config()
106
+ for part in path.split("."):
107
+ if not isinstance(node, dict) or part not in node:
108
+ return default
109
+ node = node[part]
110
+ return node
111
+
112
+
113
+ def project(name: str) -> dict | None:
114
+ """The projects[] entry with this name (case-insensitive), else None."""
115
+ for p in load_config().get("projects") or []:
116
+ if isinstance(p, dict) and str(p.get("name", "")).lower() == name.lower():
117
+ return p
118
+ return None
@@ -83,10 +83,25 @@ def _conversation_root(platform, post_id, their_comment_url):
83
83
  return f"url:{their_comment_url.rsplit('/', 1)[0]}"
84
84
 
85
85
 
86
+ # Same-author reply cap advisory (2026-07-14): a run of near-identical
87
+ # corrections to one person over a short window reads as one-note bot
88
+ # behavior even when each individual reply was earned. This is a SOFT
89
+ # nudge appended to the history block, not a hard skip, since a genuine
90
+ # direct question from the author (see get_counterparty_history_block)
91
+ # still earns a reply past the threshold; only a deterministic count-based
92
+ # gate would wrongly suppress that case.
93
+ RECENT_CAP_WINDOW_HOURS = 48
94
+ RECENT_CAP_THRESHOLD = 3
95
+
96
+
86
97
  def _fetch_author_summary(platform, author, days=7):
87
98
  """Compute bot/loop-judgment stats for `author` in the last `days` window.
88
99
 
89
- Returns a one-line summary string (or "" when there is no history).
100
+ Returns (summary_line, recent_replied_count). summary_line is "" when
101
+ there is no history. recent_replied_count is how many of our replies to
102
+ this author landed inside RECENT_CAP_WINDOW_HOURS, used for the same-
103
+ author cap advisory below.
104
+
90
105
  Signals: total candidates, our replied count, our skipped count,
91
106
  distinct conversation roots, our_replies / distinct_roots ratio (the
92
107
  "engagement-loop shape" metric — closer to 1.0 = farm-shaped), skip
@@ -94,7 +109,7 @@ def _fetch_author_summary(platform, author, days=7):
94
109
  clean), span_hours.
95
110
  """
96
111
  if not author:
97
- return ""
112
+ return "", 0
98
113
  since_ts = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
99
114
  try:
100
115
  resp = api_get(
@@ -114,11 +129,11 @@ def _fetch_author_summary(platform, author, days=7):
114
129
  f"{platform}/@{author}: {e}",
115
130
  file=sys.stderr,
116
131
  )
117
- return ""
132
+ return "", 0
118
133
 
119
134
  total = len(rows)
120
135
  if total == 0:
121
- return ""
136
+ return "", 0
122
137
 
123
138
  replied = sum(1 for r in rows if r.get("status") == "replied")
124
139
  skipped = sum(1 for r in rows if r.get("status") == "skipped")
@@ -142,13 +157,29 @@ def _fetch_author_summary(platform, author, days=7):
142
157
  if len(timestamps) >= 2:
143
158
  span_h = (max(timestamps) - min(timestamps)).total_seconds() / 3600.0
144
159
 
145
- return (
160
+ recent_cutoff = datetime.now(timezone.utc) - timedelta(hours=RECENT_CAP_WINDOW_HOURS)
161
+ recent_replied = 0
162
+ for r in rows:
163
+ if r.get("status") != "replied":
164
+ continue
165
+ ts = r.get("replied_at") or r.get("discovered_at")
166
+ if not ts:
167
+ continue
168
+ try:
169
+ dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
170
+ except Exception:
171
+ continue
172
+ if dt >= recent_cutoff:
173
+ recent_replied += 1
174
+
175
+ summary_line = (
146
176
  f"SUMMARY (last {days}d): {total} candidates, {replied} our_replies, "
147
177
  f"{skipped} skipped ({skip_pct:.1f}% skip_rate), "
148
178
  f"{distinct_roots} distinct conversation_roots "
149
179
  f"(replies/root={ratio:.2f}, closer to 1.0 = farm-shaped), "
150
180
  f"span={span_h:.1f}h"
151
181
  )
182
+ return summary_line, recent_replied
152
183
 
153
184
 
154
185
  def _fetch_dm_history(platform, author, post_id):
@@ -302,7 +333,7 @@ def get_counterparty_history_block(platform, author, current_post_id=None, curre
302
333
  sum_fut = ex.submit(_fetch_author_summary, platform, author, 7)
303
334
  same_post_disengage, dm_lines = dm_fut.result()
304
335
  pub_lines = pub_fut.result()
305
- summary_line = sum_fut.result()
336
+ summary_line, recent_replied = sum_fut.result()
306
337
 
307
338
  if not dm_lines and not pub_lines and not summary_line:
308
339
  return same_post_disengage, ""
@@ -317,6 +348,13 @@ def get_counterparty_history_block(platform, author, current_post_id=None, curre
317
348
  if summary_line:
318
349
  parts.append("")
319
350
  parts.append(summary_line)
351
+ if recent_replied >= RECENT_CAP_THRESHOLD:
352
+ parts.append(
353
+ f"\nCAUTION: we've already replied to @{author} {recent_replied} times in the "
354
+ f"last {RECENT_CAP_WINDOW_HOURS}h. Repeating the same correction again reads as "
355
+ "one-note bot behavior. Only reply again if they asked a direct question or raised "
356
+ "a genuinely new point; otherwise let this one go or shift to a different angle."
357
+ )
320
358
  if dm_lines:
321
359
  parts.append("\n### DM threads on other posts")
322
360
  parts.append("\n".join(dm_lines))
@@ -306,9 +306,13 @@ def run_claude(prompt, timeout=300, session_id=None):
306
306
  import time as _time
307
307
  import select
308
308
  usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
309
- cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose"]
310
- if session_id:
311
- cmd += ["--session-id", session_id]
309
+ # Route through run_claude.sh (2026-07-14) for session cost accounting +
310
+ # quota handling; tag engage-github is unmapped in TAG_TO_TYPE so this
311
+ # stays a direct claude -p. --session-id travels via the CLAUDE_SESSION_ID
312
+ # env (set below); the wrapper passes the flag itself, so adding it here
313
+ # too would hand claude a duplicate flag.
314
+ _run_claude_sh = os.path.join(os.path.dirname(os.path.abspath(__file__)), "run_claude.sh")
315
+ cmd = ["bash", _run_claude_sh, "engage-github", "-p", "--output-format", "stream-json", "--verbose"]
312
316
  cmd += ["--tools", "Read"]
313
317
  env = os.environ.copy()
314
318
  env.pop("ANTHROPIC_API_KEY", None) # use OAuth, not API key
@@ -583,9 +583,15 @@ def run_claude(prompt, timeout=300, session_id=None):
583
583
  import time as _time
584
584
  import select
585
585
  usage = {"input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_create": 0, "cost_usd": 0.0}
586
- cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose"]
587
- if session_id:
588
- cmd += ["--session-id", session_id]
586
+ # Route through run_claude.sh (2026-07-14) for session cost accounting +
587
+ # the dead-man's-switch quota handling every shell pipeline already gets.
588
+ # Tag engage-reddit is NOT in claude_job.py TAG_TO_TYPE, so this stays a
589
+ # direct claude -p (stream-json passes through the wrapper's tee).
590
+ # --session-id must NOT be passed here: the wrapper adds the flag itself
591
+ # and honors a caller-set CLAUDE_SESSION_ID env (set below), so passing
592
+ # it here too would hand claude a duplicate flag.
593
+ _run_claude_sh = os.path.join(os.path.dirname(os.path.abspath(__file__)), "run_claude.sh")
594
+ cmd = ["bash", _run_claude_sh, "engage-reddit", "-p", "--output-format", "stream-json", "--verbose"]
589
595
  # --bare removed: it blocks OAuth auth which we need
590
596
  cmd += ["--tools", "Bash,Read"]
591
597
  env = os.environ.copy()