@m13v/s4l 1.7.5-rc.13 → 1.7.5-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/dist/index.js CHANGED
@@ -1014,95 +1014,149 @@ async function postApproved(batchId, plan) {
1014
1014
  const approvedTwitter = approved.filter((c) => c.platform !== "reddit");
1015
1015
  let redditPosted = 0;
1016
1016
  let redditFailed = 0;
1017
+ let redditSkippedPeerDrain = false;
1017
1018
  if (approvedReddit.length) {
1018
- for (const c of approvedReddit) {
1019
- const cc = c;
1020
- const dec = cc.reddit_decision;
1021
- const meta = (cc.reddit_plan_meta || {});
1022
- if (!dec) {
1023
- c.terminal = true;
1024
- c.terminal_reason = "reddit_decision_missing";
1025
- redditFailed++;
1026
- continue;
1027
- }
1028
- // The card's reply_text is the CANONICAL text (post_drafts edits write
1029
- // there); the embedded decision still carries the draft-time original.
1030
- // Posting dec.text verbatim would silently discard a human edit.
1031
- const decPost = {
1032
- ...dec,
1033
- text: (typeof cc.reply_text === "string" && cc.reply_text.trim())
1034
- ? cc.reply_text
1035
- : dec.text,
1036
- };
1037
- if (cc.engagement_style)
1038
- decPost.engagement_style = cc.engagement_style;
1039
- const miniPlan = {
1040
- project_name: meta.project_name || cc.matched_project,
1041
- batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
1042
- phase: "draft",
1043
- decisions: [decPost],
1044
- style_assignment: meta.style_assignment || {},
1045
- generation_trace_path: meta.generation_trace_path,
1046
- session_id: meta.session_id || meta.draft_session_id,
1047
- };
1048
- const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
1049
- let r;
1019
+ // Cross-instance drain gate (2026-07-14 drain storm): every one-shot MCP
1020
+ // boot (the queue worker fires ~every minute; each boot runs the startup
1021
+ // backlog drain) drained the SAME sticky approvals concurrently — 26
1022
+ // drain plans in ~30 minutes, all fighting over the one reddit tab and
1023
+ // false-positiving each other's posts. reddit-posting-active.json is the
1024
+ // arbiter: post_reddit.py heartbeats it per row, and we stamp it around
1025
+ // the whole drain here. A fresh flag (<120s) means a peer is mid-drain:
1026
+ // leave the cards for it — approvals are sticky, nothing is lost.
1027
+ const redditFlagPath = path.join(s4lStateDir(), "reddit-posting-active.json");
1028
+ let redditPeerFresh = false;
1029
+ try {
1030
+ redditPeerFresh = Date.now() - fs.statSync(redditFlagPath).mtimeMs < 120_000;
1031
+ }
1032
+ catch {
1033
+ redditPeerFresh = false;
1034
+ }
1035
+ const stampRedditFlag = () => {
1050
1036
  try {
1051
- fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
1052
- r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
1053
- timeoutMs: 900_000,
1054
- env: {
1055
- REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
1056
- // Reviewed posts never get the active-campaign suffix (same rule
1057
- // as the twitter manual-approval path below).
1058
- S4L_SKIP_CAMPAIGN_SUFFIX: "1",
1059
- },
1060
- onLine: (line) => {
1061
- const t = line.replace(/\s+$/, "");
1062
- if (t.trim())
1063
- console.error(`[post-reddit] ${t}`);
1064
- },
1065
- });
1037
+ fs.writeFileSync(redditFlagPath, JSON.stringify({ pid: process.pid, hb: Math.floor(Date.now() / 1000) }));
1066
1038
  }
1067
- catch (err) {
1068
- r = { code: -1, stdout: "", stderr: String(err) };
1039
+ catch {
1040
+ /* best effort */
1041
+ }
1042
+ };
1043
+ if (redditPeerFresh) {
1044
+ redditSkippedPeerDrain = true;
1045
+ logPostEvent(`postApproved_reddit_skip batch=${batchId} reason=peer_drain_active cards=${approvedReddit.length}`);
1046
+ }
1047
+ else {
1048
+ stampRedditFlag();
1049
+ try {
1050
+ for (const c of approvedReddit) {
1051
+ stampRedditFlag();
1052
+ const cc = c;
1053
+ const dec = cc.reddit_decision;
1054
+ const meta = (cc.reddit_plan_meta || {});
1055
+ if (!dec) {
1056
+ c.terminal = true;
1057
+ c.terminal_reason = "reddit_decision_missing";
1058
+ redditFailed++;
1059
+ continue;
1060
+ }
1061
+ // The card's reply_text is the CANONICAL text (post_drafts edits write
1062
+ // there); the embedded decision still carries the draft-time original.
1063
+ // Posting dec.text verbatim would silently discard a human edit.
1064
+ const decPost = {
1065
+ ...dec,
1066
+ text: (typeof cc.reply_text === "string" && cc.reply_text.trim())
1067
+ ? cc.reply_text
1068
+ : dec.text,
1069
+ };
1070
+ if (cc.engagement_style)
1071
+ decPost.engagement_style = cc.engagement_style;
1072
+ const miniPlan = {
1073
+ project_name: meta.project_name || cc.matched_project,
1074
+ batch_id: cc.reddit_batch_id || meta.batch_id || "reddit-mcp-approval",
1075
+ phase: "draft",
1076
+ decisions: [decPost],
1077
+ style_assignment: meta.style_assignment || {},
1078
+ generation_trace_path: meta.generation_trace_path,
1079
+ session_id: meta.session_id || meta.draft_session_id,
1080
+ };
1081
+ const tmpPlan = path.join(process.env.S4L_TMP_DIR || "/tmp", `reddit_mcp_post_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
1082
+ let r;
1083
+ try {
1084
+ fs.writeFileSync(tmpPlan, JSON.stringify(miniPlan));
1085
+ r = await runPython("scripts/post_reddit.py", ["--phase", "post", "--in", tmpPlan], {
1086
+ timeoutMs: 900_000,
1087
+ env: {
1088
+ REDDIT_CDP_URL: process.env.REDDIT_CDP_URL || "http://127.0.0.1:9557",
1089
+ // Reviewed posts never get the active-campaign suffix (same rule
1090
+ // as the twitter manual-approval path below).
1091
+ S4L_SKIP_CAMPAIGN_SUFFIX: "1",
1092
+ },
1093
+ onLine: (line) => {
1094
+ const t = line.replace(/\s+$/, "");
1095
+ if (t.trim())
1096
+ console.error(`[post-reddit] ${t}`);
1097
+ },
1098
+ });
1099
+ }
1100
+ catch (err) {
1101
+ r = { code: -1, stdout: "", stderr: String(err) };
1102
+ }
1103
+ finally {
1104
+ try {
1105
+ fs.unlinkSync(tmpPlan);
1106
+ }
1107
+ catch {
1108
+ /* best effort */
1109
+ }
1110
+ }
1111
+ // post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
1112
+ const out = `${r.stdout}\n${r.stderr}`;
1113
+ const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
1114
+ if (r.code === 0 && postedN > 0) {
1115
+ c.posted = true;
1116
+ c.terminal = false;
1117
+ const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
1118
+ if (urlMatch)
1119
+ c.our_url = urlMatch[1];
1120
+ redditPosted++;
1121
+ }
1122
+ else {
1123
+ // Leave the approval sticky (approved && !posted && !terminal) so the
1124
+ // next post_drafts call retries, mirroring twitter's failed-drain
1125
+ // semantics; only stamp terminal on a conclusive CDP refusal.
1126
+ const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
1127
+ if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
1128
+ c.terminal = true;
1129
+ c.terminal_reason = `reddit_${cdpReason}`;
1130
+ }
1131
+ redditFailed++;
1132
+ }
1133
+ }
1069
1134
  }
1070
1135
  finally {
1136
+ // Clear only OUR stamp; a peer that took over mid-drain keeps its own.
1071
1137
  try {
1072
- fs.unlinkSync(tmpPlan);
1138
+ const cur = JSON.parse(fs.readFileSync(redditFlagPath, "utf-8"));
1139
+ if (cur && cur.pid === process.pid)
1140
+ fs.unlinkSync(redditFlagPath);
1073
1141
  }
1074
1142
  catch {
1075
1143
  /* best effort */
1076
1144
  }
1077
1145
  }
1078
- // post_reddit.py's summary marker: `[post_reddit] phase=post ... posted=N failed=M`
1079
- const out = `${r.stdout}\n${r.stderr}`;
1080
- const postedN = Number((/posted=(\d+)/.exec(out) || [])[1] || 0);
1081
- if (r.code === 0 && postedN > 0) {
1082
- c.posted = true;
1083
- c.terminal = false;
1084
- const urlMatch = /(https:\/\/(?:old\.|www\.)?reddit\.com\/r\/\S+)/.exec((/\[post_reddit\][^\n]*posted[^\n]*/i.exec(out) || [""])[0]);
1085
- if (urlMatch)
1086
- c.our_url = urlMatch[1];
1087
- redditPosted++;
1088
- }
1089
- else {
1090
- // Leave the approval sticky (approved && !posted && !terminal) so the
1091
- // next post_drafts call retries, mirroring twitter's failed-drain
1092
- // semantics; only stamp terminal on a conclusive CDP refusal.
1093
- const cdpReason = (/\[post_reddit\] CDP FAILED: ([a-z_]+)/.exec(out) || [])[1];
1094
- if (cdpReason && ["thread_locked", "thread_archived", "thread_not_found", "blocked_by_author"].includes(cdpReason)) {
1095
- c.terminal = true;
1096
- c.terminal_reason = `reddit_${cdpReason}`;
1097
- }
1098
- redditFailed++;
1099
- }
1146
+ logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
1100
1147
  }
1101
- logPostEvent(`postApproved_reddit batch=${batchId} attempted=${approvedReddit.length} posted=${redditPosted} failed=${redditFailed}`);
1102
1148
  }
1103
1149
  if (approvedTwitter.length === 0) {
1104
1150
  // All-reddit batch: persist the stamps and return without touching the
1105
1151
  // twitter preflight/lock path.
1152
+ if (redditSkippedPeerDrain) {
1153
+ return {
1154
+ attempted: 0,
1155
+ posted: 0,
1156
+ exit_code: 0,
1157
+ summary: "reddit: skipped (peer drain active); cards stay approved for the next drain",
1158
+ };
1159
+ }
1106
1160
  if (approvedReddit.length)
1107
1161
  mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1108
1162
  return {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.5-rc.13",
3
- "installedAt": "2026-07-15T01:32:54.676Z"
2
+ "version": "1.7.5-rc.15",
3
+ "installedAt": "2026-07-15T15:59:07.613Z"
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.5-rc.13",
5
+ "version": "1.7.5-rc.15",
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": {
@@ -3148,17 +3148,19 @@ class S4LMenuBar(rumps.App):
3148
3148
  self._ship_review_event(batch, decision)
3149
3149
  if not decision.get("approved"):
3150
3150
  n = decision.get("n")
3151
- # Durable local record FIRST, mirroring approved_queue_add for approvals.
3151
+ # Durable local record FIRST, mirroring the approve branch below.
3152
3152
  # review_drafts() consults this, so the rejected card is suppressed from
3153
3153
  # re-review IMMEDIATELY and even if the loopback is down when the
3154
3154
  # background plan-flag write below runs. Without this, a reject was a
3155
3155
  # fire-and-forget loopback call with a swallowed exception, so rejects
3156
3156
  # silently vanished and the card "came back" — unlike durable approvals.
3157
+ # A failure here is captured (not silently swallowed) so a broken
3158
+ # durable write is visible instead of looking identical to success.
3157
3159
  try:
3158
3160
  st.store_stamp_decision(batch, decision)
3159
3161
  self._session_decisions.append(dict(decision))
3160
- except Exception:
3161
- pass
3162
+ except Exception as e:
3163
+ _capture(e, phase="reject_stamp_decision")
3162
3164
 
3163
3165
  def _persist_reject():
3164
3166
  try:
@@ -3173,8 +3175,15 @@ class S4LMenuBar(rumps.App):
3173
3175
  # restart resumes the drain instead of stranding it and re-presenting the
3174
3176
  # card. The in-memory _post_q below is just the fast path; this file is the
3175
3177
  # source of truth review_drafts() consults to avoid re-showing it.
3176
- st.store_stamp_decision(batch, decision)
3177
- self._session_decisions.append(dict(decision))
3178
+ # Same try/except shape as the reject branch above (was previously
3179
+ # unguarded here, an asymmetry with no intentional reason): a failure is
3180
+ # captured, not silently swallowed, and the post still gets enqueued below
3181
+ # either way — this write is a resume/dedup aid, not a gate on posting.
3182
+ try:
3183
+ st.store_stamp_decision(batch, decision)
3184
+ self._session_decisions.append(dict(decision))
3185
+ except Exception as e:
3186
+ _capture(e, phase="approve_stamp_decision")
3178
3187
  with self._review_lock:
3179
3188
  self._posts_outstanding += 1
3180
3189
  self._posting_batch_total += 1
@@ -209,15 +209,6 @@ def runtime_ready() -> bool:
209
209
  return bool(py and os.path.exists(py))
210
210
 
211
211
 
212
- def _launchctl_list() -> str:
213
- try:
214
- return subprocess.run(
215
- ["launchctl", "list"], capture_output=True, text=True, timeout=10
216
- ).stdout
217
- except Exception:
218
- return ""
219
-
220
-
221
212
  def autopilot_loaded() -> bool:
222
213
  # Autopilot is now the Claude Desktop scheduled task, not the legacy launchd job.
223
214
  cfg = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(str(Path.home()), ".claude")
@@ -456,23 +447,6 @@ def snapshot():
456
447
  return out
457
448
 
458
449
 
459
- def stats_7d():
460
- """7-day post stats; loopback only (the DB read needs the owned runtime)."""
461
- res = loopback_tool("get_stats", {"days": 7})
462
- if not isinstance(res, dict):
463
- return None
464
- projects = res.get("projects")
465
- proj = projects[0] if isinstance(projects, list) and projects else None
466
- p = (proj or {}).get("posts")
467
- if not p:
468
- return None
469
- return {
470
- "posts": p.get("total", 0),
471
- "views": p.get("views_period_total", p.get("views", 0)),
472
- "replies": p.get("comments_period_total", p.get("comments", 0)),
473
- }
474
-
475
-
476
450
  # set_autopilot() (the launchd toggle) was removed: the autopilot is now the Claude
477
451
  # Desktop scheduled task `social-autoposter-autopilot`, managed in the Scheduled tab,
478
452
  # so the menu bar no longer toggles a launchd job (it mirrors the dashboard instead).
@@ -1028,8 +1002,9 @@ def review_drafts(plan, batch="review-queue"):
1028
1002
  must never be re-presented for review (approved ones proceed to post).
1029
1003
 
1030
1004
  Also excludes cards with ANY durable decision (approved, edited, rejected, or a
1031
- decided-but-failed post) via review_settled_ns(). approve/reject/edit are now
1032
- IDENTICAL: each writes a durable local record the INSTANT the user clicks, so a
1005
+ decided-but-failed post) via _ledger_items_for_plan() below. approve/reject/edit
1006
+ each write a durable local record via store_stamp_decision() the INSTANT the
1007
+ user clicks (see s4l_menubar.py::_on_card_decision), so a
1033
1008
  decided card never re-presents even if the loopback (Claude Desktop) is down
1034
1009
  when the decision's plan-flag write is attempted. The main plan's
1035
1010
  `approved`/`terminal`/`posted` flags are only stamped once the loopback write
@@ -1386,75 +1361,6 @@ def toggle_lane(lane):
1386
1361
  return write_flags(f["personal_brand"], f["promotion"])
1387
1362
 
1388
1363
 
1389
- def toggle_mode():
1390
- """Legacy whole-mode flip (mutually exclusive). Kept for old callers."""
1391
- new = (
1392
- MODE_PROMOTION
1393
- if read_mode() == MODE_PERSONAL_BRAND
1394
- else MODE_PERSONAL_BRAND
1395
- )
1396
- return write_mode(new)
1397
-
1398
-
1399
- def review_reject_add(batch, n, candidate_id=None):
1400
- """Record a REJECT the INSTANT the user clicks, mirroring approved_queue_add.
1401
- Reject and approve are now IDENTICAL: both write a durable local record before
1402
- any loopback call, so review_drafts() suppresses the card even if the loopback
1403
- (Claude Desktop) is down when the reject's plan `terminal` write is attempted.
1404
- Dedups on (batch, n) within the CURRENT plan generation (a stale item's n
1405
- refers to a dead plan and must not absorb this decision); a reject is FINAL
1406
- and overrides any earlier status."""
1407
- with _approved_lock:
1408
- gen = _plan_generation(batch)
1409
- d = read_approved_queue()
1410
- for it in d["items"]:
1411
- if it.get("batch") == batch and it.get("n") == n and not _stale_for_plan(it, gen):
1412
- if it.get("status") != "rejected":
1413
- it.update(status="rejected", error=None, ts=time_iso())
1414
- _write_approved_queue(d)
1415
- return
1416
- d["items"].append({
1417
- "batch": batch, "n": n, "candidate_id": candidate_id,
1418
- "text": "", "edited": False,
1419
- "drop_link": False, "candidate_url": "", "status": "rejected",
1420
- "error": None, "ts": time_iso(),
1421
- })
1422
- _write_approved_queue(d)
1423
-
1424
-
1425
- def approved_queue_add(batch, n, text="", edited=False, candidate_url="", drop_link=False,
1426
- candidate_id=None):
1427
- """Record an approval the INSTANT the user clicks, before any posting. Dedups
1428
- on (batch, n) within the CURRENT plan generation: re-approving a card that's
1429
- still queued/posting/posted is a no-op; a previously FAILED card is reset to
1430
- queued so it retries. A stale item (previous plan generation, same n) must
1431
- not absorb the approval or the fresh card would silently never post.
1432
-
1433
- candidate_id is the durable identity of the decision (twitter_candidates
1434
- row); n is only meaningful within one plan generation.
1435
-
1436
- drop_link carries the user's "I deleted the link while editing" intent so a
1437
- restart-resumed post honors it too (else the poster re-appends the link)."""
1438
- with _approved_lock:
1439
- gen = _plan_generation(batch)
1440
- d = read_approved_queue()
1441
- for it in d["items"]:
1442
- if it.get("batch") == batch and it.get("n") == n and not _stale_for_plan(it, gen):
1443
- if it.get("status") == "failed":
1444
- it.update(status="queued", text=text, edited=bool(edited),
1445
- drop_link=bool(drop_link), error=None, ts=time_iso())
1446
- _write_approved_queue(d)
1447
- return
1448
- d["items"].append({
1449
- "batch": batch, "n": n, "candidate_id": candidate_id,
1450
- "text": text, "edited": bool(edited),
1451
- "drop_link": bool(drop_link),
1452
- "candidate_url": candidate_url, "status": "queued",
1453
- "error": None, "ts": time_iso(),
1454
- })
1455
- _write_approved_queue(d)
1456
-
1457
-
1458
1364
  def approved_queue_set_status(batch, n, status, error=None):
1459
1365
  with _approved_lock:
1460
1366
  d = read_approved_queue()
@@ -1474,29 +1380,6 @@ def approved_queue_pending():
1474
1380
  if it.get("status") in ("queued", "posting")]
1475
1381
 
1476
1382
 
1477
- def approved_queue_active_ns(batch):
1478
- """Plan indices the user has already approved for this batch — review_drafts()
1479
- excludes these so an approved card is never re-shown. Covers queued/posting
1480
- (in flight) AND posted: relying on the plan's posted flag alone leaves a window
1481
- (and breaks if the plan is regenerated), so the durable queue excludes posted
1482
- cards independently. `failed` is intentionally NOT excluded, so a failed post
1483
- falls back to manual review rather than silently vanishing."""
1484
- gen = _plan_generation(batch)
1485
- return {it.get("n") for it in read_approved_queue()["items"]
1486
- if it.get("batch") == batch and it.get("status") in ("queued", "posting", "posted")
1487
- and not _stale_for_plan(it, gen)}
1488
-
1489
-
1490
- def review_settled_ns(batch):
1491
- """Plan indices with ANY durable user DECISION for this batch — review_drafts()
1492
- excludes these so approve, edit, and reject behave IDENTICALLY: a decided card
1493
- never re-presents for review. Covers queued/posting/posted (approved, in flight
1494
- or landed), `rejected`, AND `failed` (a decided-but-failed post is surfaced via
1495
- the failure notification + dashboard, NOT by re-showing it as a fresh review
1496
- card — that re-show was the "I already decided these came back" bug)."""
1497
- return {it.get("n") for it in _ledger_items_for_plan(batch, _plan_generation(batch))}
1498
-
1499
-
1500
1383
  def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
1501
1384
  """Post / reject drafts via the loopback tool. `post` = 1-based numbers to post
1502
1385
  as-is; `edits` = [{n, text}] to rewrite then post; `reject` = numbers to mark
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.5-rc.13",
3
+ "version": "1.7.5-rc.15",
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.5-rc.13",
3
+ "version": "1.7.5-rc.15",
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",