@m13v/s4l 1.6.203 → 1.6.204-rc.1

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.6.203",
3
- "installedAt": "2026-07-06T22:55:31.229Z"
2
+ "version": "1.6.204-rc.1",
3
+ "installedAt": "2026-07-06T23:23:51.203Z"
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.6.203",
5
+ "version": "1.6.204-rc.1",
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": {
@@ -227,6 +227,43 @@ REARM_PROMPT = (
227
227
  "Do not redo my X connection or project setup — only register the scheduled task. "
228
228
  "Keep replies short."
229
229
  )
230
+ # Universal diagnose-and-heal prompt behind the ⚠ "Diagnose & fix" menu item. One
231
+ # prompt for EVERY persistent attention state (draft stuck, rate-limited, schedule
232
+ # missing/disabled): the menu bar can only see the symptom, Claude on the box can
233
+ # see the cause. The prompt encodes the known heal patterns (the 2026-07-06
234
+ # dead-claim incident chief among them), forbids code hot patches, and ends by
235
+ # shipping a report back to us via scripts/send_diagnostic_report.py, so every
236
+ # click doubles as fleet telemetry about what actually breaks in the field.
237
+ DIAGNOSE_PROMPT_TEMPLATE = (
238
+ "The S4L plugin's menu bar on this machine is showing a persistent warning. "
239
+ "Reason code: {reason}. Detail: {detail}. Diagnose and heal it now.\n"
240
+ "Evidence lives under ~/.social-autoposter-mcp: activity.json (the producer's "
241
+ "live drafting label), claude-queue/ (pending/, running/ — each running job "
242
+ "stamps claim_pid, result/, provider.log, reaper-status.json, drain-status.json) "
243
+ "and the reaper log at repo/package/skill/logs/launchd-claude-reaper-stderr.log. "
244
+ "Worker transcripts: the *.jsonl files in the ~/.claude/projects/ entry for the "
245
+ "~/.s4l-worker directory.\n"
246
+ "Known heal patterns, in order of likelihood: "
247
+ "(1) dead claim — a job in claude-queue/running/ whose claim_pid no longer "
248
+ "exists in ps was orphaned by a killed worker (app quit mid-draft); move its "
249
+ "json back to claude-queue/pending/<its type>/ under the SAME filename with the "
250
+ "claim_pid and claimed_at keys deleted, and the every-minute worker will "
251
+ "re-claim it. "
252
+ "(2) rate-limited — worker transcripts end in 429/limit errors; nothing local "
253
+ "to fix, say so in the report. "
254
+ "(3) schedule missing/disabled — re-register via queue_setup or the host "
255
+ "create_scheduled_task with the prompt from "
256
+ "~/.claude/scheduled-tasks/s4l-worker/SKILL.md.\n"
257
+ "HARD RULE: fix state files only. Never edit, copy, or patch code in "
258
+ "~/.social-autoposter-mcp or any extension bundle (hot patches are auto-reverted "
259
+ "and mask the real bug).\n"
260
+ "Finally — ALWAYS, healed or not — write a short markdown report (symptom, root "
261
+ "cause, actions taken, current state) to "
262
+ "~/.social-autoposter-mcp/diagnostics/report-<UTC-timestamp>.md and run "
263
+ "`~/.social-autoposter-mcp/runtime/.venv/bin/python3 "
264
+ "~/.social-autoposter-mcp/repo/package/scripts/send_diagnostic_report.py "
265
+ "<that file>` so the report reaches the S4L developers. Keep replies short."
266
+ )
230
267
 
231
268
  # A pending draft job older than this (seconds) with nothing claiming it means no
232
269
  # routine is draining the queue — the worker would claim within a minute if it
@@ -517,6 +554,29 @@ class S4LMenuBar(rumps.App):
517
554
  )
518
555
 
519
556
 
557
+ def _diagnose_fix(self, _=None):
558
+ """Universal ⚠ escape hatch: hand Claude a diagnose-and-heal prompt for
559
+ whatever persistent attention state the menu bar is showing, via the same
560
+ clipboard-prompt flow as Set up / Re-arm. The prompt makes Claude ship a
561
+ report back through send_diagnostic_report.py, so we hear about every
562
+ field failure this button gets used on — the click itself is also
563
+ captured, so \"clicked but no report arrived\" is a visible signal."""
564
+ reason, detail = getattr(self, "_stall_reason_info", ("", "")) or ("", "")
565
+ if not reason:
566
+ sched = getattr(self, "_schedule_state_cache", "") or ""
567
+ reason = f"schedule_{sched}" if sched in ("missing", "disabled") else "unknown"
568
+ _capture_msg(
569
+ "S4L diagnose&fix clicked",
570
+ phase="diagnose_fix",
571
+ reason=reason,
572
+ )
573
+ self._clipboard_prompt(
574
+ DIAGNOSE_PROMPT_TEMPLATE.format(reason=reason, detail=detail or "n/a"),
575
+ "Diagnose & fix S4L in Claude",
576
+ "Claude will diagnose the warning, heal what it safely can, and send "
577
+ "a report to the S4L developers",
578
+ )
579
+
520
580
  def _rearm(self, _=None):
521
581
  """Register the draft schedule for the CURRENT account via the host
522
582
  create_scheduled_task flow (same as onboarding) — it registers under
@@ -2409,6 +2469,10 @@ class S4LMenuBar(rumps.App):
2409
2469
  else:
2410
2470
  items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
2411
2471
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2472
+ # Universal escape hatch for EVERY persistent ⚠ (the draft_stuck and
2473
+ # rate_limited branches previously dead-ended with labels only): hand
2474
+ # Claude a diagnose-and-heal prompt that also reports back to us.
2475
+ items.append(rumps.MenuItem("Diagnose & fix in Claude…", callback=self._diagnose_fix))
2412
2476
  items.append(rumps.separator)
2413
2477
 
2414
2478
  if not runtime_ready:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.6.203",
3
+ "version": "1.6.204-rc.1",
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.6.203",
3
+ "version": "1.6.204-rc.1",
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",
@@ -637,6 +637,133 @@ def _env_int(name: str, default: int) -> int:
637
637
  return default
638
638
 
639
639
 
640
+ # A claim whose agent-session pid has been ABSENT from the full ps snapshot for this
641
+ # many consecutive reaper cycles is provably dead — requeue its job. Two cycles (not
642
+ # one) so a single torn/raced ps snapshot can never orphan-then-duplicate a live
643
+ # draft. The 2026-07-06 incident this fixes: quitting the Claude app mid-draft killed
644
+ # the claim-holder; the job sat claimed in running/ for the producer's full 30-minute
645
+ # timeout while the menu bar showed ⚠, even though this reaper logged the dead pid
646
+ # every cycle. Absence from the process table is the kernel's word that the process
647
+ # is gone; the only wrong-verdict risk is ps itself failing, which the empty-snapshot
648
+ # guard below covers (pid REUSE makes a dead pid look alive — never the reverse — so
649
+ # reuse can only delay this path, never mistrigger it).
650
+ STALE_CLAIM_STRIKES = _env_int("S4L_REAPER_STALE_CLAIM_STRIKES", 2)
651
+
652
+
653
+ def requeue_dead_claims(by_pid, dry=False):
654
+ """Move running/ jobs whose claim-holder session is provably dead back to
655
+ pending/<type>/ so the next scheduled worker re-claims them within a tick.
656
+
657
+ This is the exact inverse of the atomic claim rename in claude_job.py::cmd_next
658
+ (pending/<type>/<name> -> running/<name>), with the stale claim stamp scrubbed
659
+ so the re-claiming worker stamps fresh. The producer is unaffected: it only
660
+ polls the result path once claimed_at is set, and its timeout still removes
661
+ both the pending and running paths, so a requeued job cannot outlive its
662
+ producer. Strike counts persist in claude-queue/stale-claim-strikes.json.
663
+ Returns the number of jobs requeued. Best-effort: never raises."""
664
+ requeued = 0
665
+ try:
666
+ qroot = os.path.join(_state_dir(), "claude-queue")
667
+ running = os.path.join(qroot, "running")
668
+ strikes_path = os.path.join(qroot, "stale-claim-strikes.json")
669
+ try:
670
+ names = [
671
+ n for n in os.listdir(running)
672
+ if n.endswith(".json") and not n.endswith(".tmp")
673
+ ]
674
+ except OSError:
675
+ return 0
676
+ try:
677
+ with open(strikes_path) as f:
678
+ strikes = json.load(f)
679
+ if not isinstance(strikes, dict):
680
+ strikes = {}
681
+ except Exception:
682
+ strikes = {}
683
+ # ps failed / returned nothing: EVERY pid would look dead. Don't strike.
684
+ if not by_pid:
685
+ return 0
686
+ seen = set()
687
+ for name in names:
688
+ path = os.path.join(running, name)
689
+ try:
690
+ with open(path) as f:
691
+ job = json.load(f)
692
+ except Exception:
693
+ continue
694
+ pid = job.get("claim_pid")
695
+ if not isinstance(pid, int) or pid <= 1:
696
+ continue # unstamped claim: nothing to test liveness against
697
+ seen.add(name)
698
+ if pid in by_pid:
699
+ strikes.pop(name, None) # holder alive -> clean slate
700
+ continue
701
+ entry = strikes.get(name)
702
+ if not isinstance(entry, dict) or entry.get("pid") != pid:
703
+ entry = {"pid": pid, "strikes": 0}
704
+ entry["strikes"] = int(entry.get("strikes", 0)) + 1
705
+ strikes[name] = entry
706
+ if entry["strikes"] < STALE_CLAIM_STRIKES:
707
+ continue
708
+ qtype = job.get("type") or "twitter-prep"
709
+ pend_dir = os.path.join(qroot, "pending", str(qtype))
710
+ print(
711
+ f"[claude-reaper] releasing stale claim: job {name} held by dead"
712
+ f" pid {pid} for {entry['strikes']} cycles -> requeue to"
713
+ f" pending/{qtype}",
714
+ file=sys.stderr,
715
+ )
716
+ if dry:
717
+ continue
718
+ try:
719
+ job.pop("claim_pid", None)
720
+ job.pop("claimed_at", None)
721
+ os.makedirs(pend_dir, exist_ok=True)
722
+ dst = os.path.join(pend_dir, name)
723
+ tmp = dst + f".tmp.{os.getpid()}"
724
+ with open(tmp, "w") as f:
725
+ json.dump(job, f)
726
+ os.replace(tmp, dst)
727
+ os.remove(path)
728
+ strikes.pop(name, None)
729
+ requeued += 1
730
+ except Exception as e:
731
+ print(
732
+ f"[claude-reaper] requeue of {name} failed: {e}",
733
+ file=sys.stderr,
734
+ )
735
+ continue
736
+ # Fleet visibility: a released claim means a worker died mid-draft on
737
+ # this install (app quit, crash, logout). Same lane as the reaper's
738
+ # crash telemetry; best-effort.
739
+ try:
740
+ import sentry_init
741
+ sentry_init.init()
742
+ sentry_init.capture_message(
743
+ f"S4L reaper requeued stale claim (dead pid {pid}): {name}",
744
+ level="warning",
745
+ tags={"component": "claude_reaper", "phase": "stale_claim_requeue"},
746
+ )
747
+ sentry_init.flush(2.0)
748
+ except Exception:
749
+ pass
750
+ # Drop strike entries for jobs that left running/ (drained or timed out).
751
+ for name in list(strikes):
752
+ if name not in seen:
753
+ strikes.pop(name, None)
754
+ if not dry:
755
+ try:
756
+ tmp = strikes_path + f".tmp.{os.getpid()}"
757
+ with open(tmp, "w") as f:
758
+ json.dump(strikes, f)
759
+ os.replace(tmp, strikes_path)
760
+ except Exception:
761
+ pass
762
+ except Exception:
763
+ return requeued
764
+ return requeued
765
+
766
+
640
767
  def main() -> int:
641
768
  dry = "--dry-run" in sys.argv
642
769
  max_age = _env_int("S4L_REAPER_MAX_AGE_SEC", DEFAULT_MAX_AGE_SEC)
@@ -777,6 +904,12 @@ def main() -> int:
777
904
  file=sys.stderr,
778
905
  )
779
906
 
907
+ # Act on the dead set (not just log it): release claims whose holder has been
908
+ # gone from the process table for STALE_CLAIM_STRIKES consecutive cycles, so a
909
+ # worker killed mid-draft (app quit, crash, logout) costs the producer ~2-3
910
+ # minutes instead of its full 30-minute timeout behind a ⚠ menu bar.
911
+ requeued_claims = requeue_dead_claims(by_pid, dry=dry)
912
+
780
913
  live_pids = set(meta.keys())
781
914
 
782
915
  killed = 0
@@ -843,6 +976,7 @@ def main() -> int:
843
976
  "disclaimer_killed": disclaimers,
844
977
  "macos_mcp_killed": macos_killed,
845
978
  "archived_sessions": archived_sessions,
979
+ "requeued_stale_claims": requeued_claims,
846
980
  "spared_claim_pids": sorted(claim_pids),
847
981
  "worker_probe_seen": stats["worker_probe_seen"],
848
982
  "reapable_workers": stats["reapable_workers"],
@@ -54,7 +54,9 @@
54
54
  # bash scripts/release-mcpb.sh --no-release # build + pack + verify only (no npm, no GitHub)
55
55
  # bash scripts/release-mcpb.sh --draft # GitHub release as a draft
56
56
  # bash scripts/release-mcpb.sh --staging # PRE-release -rc.N (staging channel only)
57
- # bash scripts/release-mcpb.sh --promote v1.6.193-rc.2 # ship a tested pre-release to stable
57
+ # bash scripts/release-mcpb.sh --promote v1.6.193-rc.2 # BLOCKED for -rc tags: stable must
58
+ # # carry clean digits (user rule, 2026-07-06). Commit + push,
59
+ # # then cut stable with --version X.Y.Z. ALLOW_RC_PROMOTE=1 forces.
58
60
 
59
61
  set -euo pipefail
60
62
 
@@ -117,14 +119,28 @@ command -v node >/dev/null || die "node not found on PATH"
117
119
  # Flip the SAME artifact the staging box tested: clear GitHub's prerelease flag
118
120
  # and mark it latest (so releases/latest + the stable boxes pick it up), and move
119
121
  # npm's `latest` dist-tag onto it. Byte-for-byte identical to what was tested;
120
- # there is no repack, so nothing can drift between test and ship. The version
121
- # keeps its -rc.N label on purpose (that IS the tested build); cut a fresh stable
122
- # patch later if you want a clean number.
122
+ # there is no repack, so nothing can drift between test and ship.
123
+ #
124
+ # USER RULE (2026-07-06, after the SECOND in-place rc promote; the first left
125
+ # v1.6.197-rc.16 flagged stable on 2026-07-03): the stable channel must show
126
+ # clean version digits, never an -rc.N label. So promoting an -rc tag in place
127
+ # is BLOCKED by default. Ship the same code with a clean number instead:
128
+ # commit + push (the pack ships the working tree), then
129
+ # `bash scripts/release-mcpb.sh --version X.Y.Z`. Set ALLOW_RC_PROMOTE=1 only
130
+ # for an emergency where a rebuild is riskier than the label.
123
131
  if [[ -n "$PROMOTE_TAG" ]]; then
124
132
  command -v gh >/dev/null || die "gh CLI not found"
125
133
  gh auth status >/dev/null 2>&1 || die "gh not authenticated (run: gh auth login)"
126
134
  PTAG="$PROMOTE_TAG"; [[ "$PTAG" == v* ]] || PTAG="v$PTAG"
127
135
  PVER="${PTAG#v}"
136
+ if [[ "$PVER" == *-rc* && "${ALLOW_RC_PROMOTE:-0}" != "1" ]]; then
137
+ CLEAN_VER="${PVER%%-rc*}"
138
+ die "stable releases carry clean digits (user rule, 2026-07-06); refusing to promote $PTAG in place.
139
+ Ship the same code under a clean number instead:
140
+ 1. commit + push the repo (the pipeline pack ships the working tree)
141
+ 2. bash scripts/release-mcpb.sh --version $CLEAN_VER (pick the next patch if $CLEAN_VER is already published)
142
+ Emergency override (ships the -rc label to every stable box): ALLOW_RC_PROMOTE=1 bash scripts/release-mcpb.sh --promote $PTAG"
143
+ fi
128
144
  gh release view "$PTAG" -R "$GH_REPO" >/dev/null 2>&1 || die "no release $PTAG to promote"
129
145
  say "Promoting $PTAG to stable (in place; same tested artifact, no rebuild)"
130
146
  gh release edit "$PTAG" -R "$GH_REPO" --prerelease=false --latest
@@ -493,7 +509,8 @@ if [[ -n "$DRAFT_FLAG" ]]; then
493
509
  elif [[ "$DO_STAGING" == "1" ]]; then
494
510
  say "Staging pre-release — releases/latest deliberately EXCLUDES it, so stable boxes stay put."
495
511
  echo " Only boxes on the staging channel pull $TAG (via the releases LIST endpoint)."
496
- echo " To ship it to everyone once tested: bash scripts/release-mcpb.sh --promote $TAG"
512
+ echo " To ship it to everyone once tested (stable = clean digits, never -rc): commit + push, then"
513
+ echo " bash scripts/release-mcpb.sh --version ${VERSION%%-rc*} # pick the next patch if that version is already published"
497
514
  else
498
515
  say "Verifying releases/latest serves $TAG (drives the menu-bar update banner)"
499
516
  LATEST_SEEN=""
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """Ship a menu-bar "Diagnose & fix" report back to the S4L developers.
3
+
4
+ Usage: send_diagnostic_report.py <report.md> [reason-code]
5
+
6
+ The menu bar's ⚠ "Diagnose & fix in Claude…" item hands Claude a prompt that ends
7
+ with: write a short markdown report of the diagnosis (symptom, root cause, actions
8
+ taken, current state) and run this script on it. We ship it over the same Sentry
9
+ lane the reaper and menu bar already use (sentry_init tags every event with the
10
+ install identity), so field diagnoses land next to the "autopilot needs attention"
11
+ warnings they resolve. The report body rides in the message itself, truncated to
12
+ stay inside Sentry's message limits; the full file stays on disk under
13
+ ~/.social-autoposter-mcp/diagnostics/ for follow-up over the QA/SSH lane.
14
+
15
+ Exit codes: 0 shipped, 1 usage / unreadable file, 2 telemetry unavailable (the
16
+ report file still exists locally either way — say so, never lose the diagnosis).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import sys
23
+
24
+ # 8KB is Sentry's formatted-message ceiling; leave headroom for the header line.
25
+ MAX_BODY_CHARS = 6000
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+
29
+
30
+ def main() -> int:
31
+ if len(sys.argv) < 2:
32
+ print("usage: send_diagnostic_report.py <report.md> [reason-code]", file=sys.stderr)
33
+ return 1
34
+ path = sys.argv[1]
35
+ reason = sys.argv[2] if len(sys.argv) > 2 else "unspecified"
36
+ try:
37
+ with open(path, encoding="utf-8", errors="replace") as f:
38
+ body = f.read().strip()
39
+ except OSError as e:
40
+ print(f"cannot read report file: {e}", file=sys.stderr)
41
+ return 1
42
+ if not body:
43
+ print("report file is empty — write the diagnosis first", file=sys.stderr)
44
+ return 1
45
+ truncated = len(body) > MAX_BODY_CHARS
46
+ if truncated:
47
+ body = body[:MAX_BODY_CHARS] + "\n…[truncated; full report on the box]"
48
+
49
+ try:
50
+ import sentry_init
51
+ sentry_init.init()
52
+ sentry_init.capture_message(
53
+ "S4L field diagnosis report\n\n" + body,
54
+ level="warning",
55
+ tags={
56
+ "component": "diagnose_fix",
57
+ "phase": "field_report",
58
+ "reason": reason,
59
+ "truncated": str(truncated).lower(),
60
+ },
61
+ )
62
+ sentry_init.flush(5.0)
63
+ except Exception as e:
64
+ print(
65
+ f"telemetry unavailable ({e}); report kept locally at {path}",
66
+ file=sys.stderr,
67
+ )
68
+ return 2
69
+ print(f"report shipped to S4L telemetry (kept locally at {path})")
70
+ return 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ sys.exit(main())