@m13v/s4l 1.7.6-rc.6 → 1.7.6-rc.8

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
@@ -19,7 +19,7 @@ import { screencast, bringBrowserToFront } from "./screencast.js";
19
19
  import os from "node:os";
20
20
  import path from "node:path";
21
21
  import fs from "node:fs";
22
- import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
22
+ import { repoDir, runPython, run, readPlan, writePlan, planPath, TMP_DIR, } from "./repo.js";
23
23
  import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, ensureConfigInStateDir, normalizeStringList, recordRedditAccount, } from "./setup.js";
24
24
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
25
25
  import { redditStatus, redditConnect, redditDetectSources, summarizeRedditAuth, } from "./redditAuth.js";
@@ -929,7 +929,37 @@ function isSandboxCandidate(c) {
929
929
  const id = Number(c.candidate_id);
930
930
  return Number.isFinite(id) && id >= 900_000_000;
931
931
  }
932
- function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
932
+ // Write field patches into the review-queue store UNDER ITS LOCK by shelling
933
+ // to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
934
+ // _store_update and merge_review_queue.py hold around their read-modify-write.
935
+ // Node has no native flock, and this process writing the store directly was
936
+ // the last unlocked writer (the race that erased posted stamps on 2026-07-17).
937
+ // Returns false on any failure so callers can fall back to the legacy write.
938
+ async function patchReviewStore(patches) {
939
+ if (!patches.length)
940
+ return true;
941
+ const tmp = path.join(TMP_DIR, `s4l-store-patches-${process.pid}-${Date.now()}.json`);
942
+ try {
943
+ fs.writeFileSync(tmp, JSON.stringify({ patches }), "utf-8");
944
+ const res = await runPython("scripts/store_patch.py", [tmp], {
945
+ timeoutMs: 30_000,
946
+ env: { S4L_REPO_DIR: repoDir(), PATH: pipelinePath() },
947
+ });
948
+ return res.code === 0;
949
+ }
950
+ catch {
951
+ return false;
952
+ }
953
+ finally {
954
+ try {
955
+ fs.unlinkSync(tmp);
956
+ }
957
+ catch {
958
+ /* best effort */
959
+ }
960
+ }
961
+ }
962
+ async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
933
963
  // Merge posted/terminal stamps into a FRESH read of the store instead of
934
964
  // rewriting the whole plan from the copy taken minutes ago. The old
935
965
  // whole-file write was last-writer-wins: while a batch posted, the menubar
@@ -941,6 +971,46 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
941
971
  // overwrites a fresh `posted=true`. Fallback: candidates without a
942
972
  // candidate_id can't be matched into the fresh copy, so keep the legacy
943
973
  // whole-plan write for those older plans.
974
+ //
975
+ // Review-queue store: go through the LOCKED patch path (store_patch.py)
976
+ // first. The fresh-read merge below closes most of the race window but not
977
+ // all of it — a menubar decision landing between our readPlan and writePlan
978
+ // still gets erased. The locked path holds the store's flock for the whole
979
+ // read-mutate-replace, applies to every sibling row sharing a candidate_id,
980
+ // and enforces the same posted-sticky rules. Legacy path stays as the
981
+ // fallback and for per-batch /tmp plans (single writer, no lock needed).
982
+ try {
983
+ const mergeableForPatch = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
984
+ if (batchId === REVIEW_QUEUE_ID && mergeableForPatch) {
985
+ const patches = stamped.map((c) => {
986
+ const set = {};
987
+ const unset = [];
988
+ if (c.posted === true) {
989
+ set.posted = true;
990
+ set.terminal = false;
991
+ if (c.our_url)
992
+ set.our_url = c.our_url;
993
+ unset.push("discard_reason");
994
+ }
995
+ else if (c.terminal === true) {
996
+ set.terminal = true;
997
+ set.terminal_reason = c.terminal_reason ?? null;
998
+ // See the zombie-card note in the legacy branch below: a terminal
999
+ // from a real post attempt must clear the overridable expiry stamp.
1000
+ unset.push("discard_reason");
1001
+ }
1002
+ if (typeof c.post_attempts === "number")
1003
+ set.post_attempts = c.post_attempts;
1004
+ return { candidate_id: c.candidate_id, set, unset };
1005
+ });
1006
+ if (await patchReviewStore(patches))
1007
+ return;
1008
+ console.error("[post] store_patch.py failed; falling back to unlocked stamp merge");
1009
+ }
1010
+ }
1011
+ catch {
1012
+ /* fall through to the legacy write */
1013
+ }
944
1014
  try {
945
1015
  const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
946
1016
  const fresh = mergeable ? readPlan(batchId) : null;
@@ -1290,7 +1360,7 @@ async function postApproved(batchId, plan) {
1290
1360
  };
1291
1361
  }
1292
1362
  if (approvedReddit.length)
1293
- mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1363
+ await mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
1294
1364
  return {
1295
1365
  attempted: approvedReddit.length,
1296
1366
  posted: redditPosted,
@@ -1560,7 +1630,7 @@ async function postApproved(batchId, plan) {
1560
1630
  // Reddit stamps (set in the reddit drain above) merge alongside the twitter
1561
1631
  // ones: `approved` here spans both platforms.
1562
1632
  if (touchedPlan || redditPosted || redditFailed) {
1563
- mergeApprovedStampsIntoStore(batchId, plan, approved);
1633
+ await mergeApprovedStampsIntoStore(batchId, plan, approved);
1564
1634
  }
1565
1635
  // Post failures are HANDLED in the pipeline (it returns a count, never throws),
1566
1636
  // so they never reach Sentry on their own. Capture an explicit event whenever
@@ -2805,6 +2875,16 @@ tool("post_drafts", {
2805
2875
  const total = candidates.length;
2806
2876
  const warnings = [];
2807
2877
  const inRange = (n) => n >= 1 && n <= total;
2878
+ // Review-queue store: snapshot every row now so the write below can be a
2879
+ // field-level DIFF applied under the store lock (store_patch.py) instead
2880
+ // of an unlocked whole-file replace. This function holds its in-memory
2881
+ // plan across user think-time; a whole-file write here erased any menubar
2882
+ // decision or merge that landed in between (the 2026-07-17 truth-loss
2883
+ // family). Non-store batches keep the plain write: single writer.
2884
+ const isStoreBatch = batch_id === REVIEW_QUEUE_ID;
2885
+ const rowsBefore = isStoreBatch
2886
+ ? candidates.map((c) => JSON.stringify(c))
2887
+ : [];
2808
2888
  // ---- Rejections: durable + final --------------------------------------
2809
2889
  // A rejected draft is marked terminal so it NEVER re-appears for review and is
2810
2890
  // never posted. A reject overrides any earlier approve on the same card.
@@ -2941,7 +3021,40 @@ tool("post_drafts", {
2941
3021
  if (c)
2942
3022
  c.approved = true;
2943
3023
  });
2944
- writePlan(batch_id, plan);
3024
+ // Persist the decision mutations. Store batch: diff each row against its
3025
+ // snapshot and apply only the changed fields under the store lock, so a
3026
+ // concurrent menubar decision or merge is never erased. Anything else
3027
+ // (per-batch /tmp plans): plain write, single writer.
3028
+ let storeWriteDone = false;
3029
+ if (isStoreBatch) {
3030
+ const patches = [];
3031
+ candidates.forEach((c, i) => {
3032
+ const beforeRaw = rowsBefore[i];
3033
+ const afterRaw = JSON.stringify(c);
3034
+ if (beforeRaw === afterRaw)
3035
+ return;
3036
+ const before = JSON.parse(beforeRaw ?? "{}");
3037
+ const after = JSON.parse(afterRaw);
3038
+ const set = {};
3039
+ const unset = [];
3040
+ for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
3041
+ if (!(k in after) || after[k] === undefined) {
3042
+ if (k in before)
3043
+ unset.push(k);
3044
+ }
3045
+ else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
3046
+ set[k] = after[k];
3047
+ }
3048
+ }
3049
+ if (Object.keys(set).length || unset.length)
3050
+ patches.push({ candidate_id: c.candidate_id ?? null, n: i + 1, set, unset });
3051
+ });
3052
+ storeWriteDone = await patchReviewStore(patches);
3053
+ if (!storeWriteDone)
3054
+ console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
3055
+ }
3056
+ if (!storeWriteDone)
3057
+ writePlan(batch_id, plan);
2945
3058
  if (approve.size === 0) {
2946
3059
  return jsonContent({
2947
3060
  batch_id,
@@ -301,7 +301,8 @@ function collectStateSnapshot() {
301
301
  ["install_progress", "install-progress.json", 64_000],
302
302
  ["onboarding_progress", "onboarding-progress.json", 256_000],
303
303
  ["review_queue", "review-queue.json", 256_000],
304
- ["approved_queue", "approved-queue.json", 256_000],
304
+ // approved-queue.json removed 2026-07-17: the ledger is gone; the review
305
+ // store is the only local decision record.
305
306
  ];
306
307
  for (const [key, file, cap] of stateFiles) {
307
308
  const val = readJsonCapped(path.join(stateDir, file), cap);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.6",
3
- "installedAt": "2026-07-17T17:26:23.362Z"
2
+ "version": "1.7.6-rc.8",
3
+ "installedAt": "2026-07-17T17:51:37.267Z"
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-rc.6",
5
+ "version": "1.7.6-rc.8",
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": {
@@ -2331,7 +2331,6 @@ class S4LMenuBar(rumps.App):
2331
2331
  for it in pending:
2332
2332
  batch, n = it.get("batch"), it.get("n")
2333
2333
  if n in posted_ns:
2334
- st.approved_queue_set_status(batch, n, "posted") # reconcile lost update
2335
2334
  continue
2336
2335
  decision = {
2337
2336
  "n": n,
@@ -2939,11 +2938,14 @@ class S4LMenuBar(rumps.App):
2939
2938
  flight, and not snoozed (the snooze label and gate take precedence)."""
2940
2939
  if pending_count <= 0 or self._review_active:
2941
2940
  return 0.0
2942
- # The reveal cadence is a cards-mode concept (its menu control is
2943
- # hidden in canvas mode, 2026-07-16); never hold canvas presentations
2944
- # on a stale cadence value the user can no longer see or change.
2941
+ # Canvas never auto-pops (2026-07-17, user direction): the explicit
2942
+ # "Review N pending drafts" click is the ONLY way to open it. Report
2943
+ # the same indefinite hold as the cards-mode "Never" preset so the
2944
+ # menu shows the courtesy line. The reveal cadence itself remains a
2945
+ # cards-mode concept (its menu control is hidden in canvas mode,
2946
+ # 2026-07-16), so a stale cadence value is never consulted here.
2945
2947
  if st.read_review_layout() != "cards":
2946
- return 0.0
2948
+ return -1.0
2947
2949
  now = time.time()
2948
2950
  if now < self._review_snooze_until:
2949
2951
  return 0.0
@@ -3064,12 +3066,20 @@ class S4LMenuBar(rumps.App):
3064
3066
  # card keeps growing live) and skipped for focus=True (the menu's
3065
3067
  # "Review N pending drafts" is an explicit ask). _last_review_sig is NOT
3066
3068
  # stamped on a hold, so the same set still presents fresh when due.
3067
- # Cards mode only (2026-07-16): the cadence control is hidden while
3068
- # View Mode is Canvas, so a stale value must not silently pace or
3069
- # suppress canvas presentations. No panel is open past the
3070
- # _review_active branch above, so the preference IS the layout that
3071
- # is about to present.
3072
- if not focus and st.read_review_layout() == "cards":
3069
+ # No panel is open past the _review_active branch above, so the
3070
+ # preference IS the layout that is about to present.
3071
+ if not focus:
3072
+ if st.read_review_layout() != "cards":
3073
+ # Canvas never auto-pops (2026-07-17, user direction): the
3074
+ # explicit "Review N pending drafts" click (focus=True) is the
3075
+ # ONLY way to open the canvas. The extend-open branch above
3076
+ # still grows an already-open canvas live; _last_review_sig is
3077
+ # NOT stamped here, so the whole backlog presents fresh on the
3078
+ # click.
3079
+ return
3080
+ # Cards mode: pace fresh pop-ups by the reveal cadence. The
3081
+ # cadence control is a cards-only concept (hidden while View Mode
3082
+ # is Canvas, 2026-07-16), so it is never consulted for canvas.
3073
3083
  cadence = st.read_reveal_cadence()
3074
3084
  if cadence < 0:
3075
3085
  # "Never" preset: cards never auto-pop, only an explicit
@@ -3581,7 +3591,6 @@ class S4LMenuBar(rumps.App):
3581
3591
  # No "Posting draft N…" banner: the menu-bar spinner already shows
3582
3592
  # live posting progress, so a Notification Center toast per approved
3583
3593
  # card is pure noise. Only failures (below) raise a notification.
3584
- st.approved_queue_set_status(batch, n, "posting")
3585
3594
  with self._review_lock:
3586
3595
  activity_label = self._posting_activity_label_locked()
3587
3596
  cl = [n] if decision.get("drop_link") else None
@@ -3613,32 +3622,25 @@ class S4LMenuBar(rumps.App):
3613
3622
  # one registered). Unlike a real posting failure, nothing
3614
3623
  # was ever attempted here — post_drafts never reached a
3615
3624
  # server — so there's no double-post risk in retrying.
3616
- # Deliberately do NOT call store_mark_post_failed / set
3617
- # status "failed": both would exclude this card from
3618
- # store_pending_posts()/approved_queue_pending(), which is
3625
+ # Deliberately do NOT call store_mark_post_failed: it would
3626
+ # exclude this card from store_pending_posts(), which is
3619
3627
  # exactly what _resume_approved_queue() reads on the next
3620
- # unreachable->reachable edge. Leaving it "queued" lets
3621
- # that existing restart-recovery path retry it
3622
- # automatically once a server re-registers, instead of
3623
- # stranding it for manual dashboard retry.
3624
- st.approved_queue_set_status(batch, n, "queued", error="loopback_unreachable")
3628
+ # unreachable->reachable edge. Leaving the store row
3629
+ # approved-unposted lets that existing restart-recovery
3630
+ # path retry it automatically once a server re-registers,
3631
+ # instead of stranding it for manual dashboard retry.
3625
3632
  self._notify(
3626
3633
  "S4L", "Server unreachable — will retry automatically once Claude Desktop reconnects."
3627
3634
  )
3628
3635
  else:
3629
3636
  posted = res.get("posted") if isinstance(res, dict) else None
3630
3637
  if posted == 0:
3631
- st.approved_queue_set_status(batch, n, "failed", error="posted_0")
3632
3638
  st.store_mark_post_failed(batch, n, decision.get("candidate_id"), "posted_0")
3633
3639
  self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
3634
- else:
3635
- # Success is silent: the spinner + dashboard already reflect
3636
- # it. No per-card "Posted draft N." banner. The server
3637
- # stamps posted/our_url into the store itself; the legacy
3638
- # set_status only settles pre-store ledger entries.
3639
- st.approved_queue_set_status(batch, n, "posted")
3640
+ # Success is silent: the spinner + dashboard already reflect
3641
+ # it. No per-card "Posted draft N." banner. The server
3642
+ # stamps posted/our_url into the store itself.
3640
3643
  except Exception as e:
3641
- st.approved_queue_set_status(batch, n, "failed", error=str(e)[:200])
3642
3644
  st.store_mark_post_failed(batch, n, decision.get("candidate_id"), str(e)[:200])
3643
3645
  sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
3644
3646
  sys.stderr.flush()
@@ -4078,7 +4080,12 @@ class S4LMenuBar(rumps.App):
4078
4080
  # now regardless.
4079
4081
  hold_until = self._reveal_hold_until(pending_count)
4080
4082
  if hold_until < 0:
4081
- items.append(self._label("Cards hidden — see count in the menu bar"))
4083
+ if st.read_review_layout() == "cards":
4084
+ items.append(self._label("Cards hidden — see count in the menu bar"))
4085
+ else:
4086
+ # Canvas mode: it never auto-opens; the review item above
4087
+ # is the one way in.
4088
+ items.append(self._label("Canvas opens from the review item above"))
4082
4089
  elif hold_until:
4083
4090
  items.append(
4084
4091
  self._label(
@@ -43,10 +43,6 @@ try:
43
43
  except Exception:
44
44
  pass
45
45
 
46
- # Serializes read-modify-write on approved-queue.json. The menu bar's main thread
47
- # (approve click / restart resume) and the post-worker thread (status updates)
48
- # both mutate it; without this a concurrent interleave would drop an approval.
49
- _approved_lock = threading.Lock()
50
46
 
51
47
  # Mirrors shared/onboarding-ledger.cjs MILESTONES (same order). The ledger's
52
48
  # OPTIONAL milestones (reddit_connected / reddit_verified) are deliberately NOT
@@ -982,89 +978,23 @@ def review_queue_posted_count():
982
978
  return sum(1 for c in cands if candidate_state(c) == "posted")
983
979
 
984
980
 
985
- def _plan_generation(batch):
986
- """created_at of the CURRENT review plan for this batch (stamped by
987
- merge_review_queue.py when it starts a fresh plan), or None for plans
988
- written before generation stamping existed.
989
-
990
- Why this matters: the plan lives in /tmp and dies on every reboot or tmp
991
- sweep, and its candidate numbering restarts at 1, while approved-queue.json
992
- lives in the state dir forever. Without a generation marker, ledger entries
993
- from a dead plan match the new plan's low indices and every fresh draft is
994
- treated as already-decided: the 2026-07-03 "unapproved cards never show up"
995
- bug (30 stale entries silently swallowed a whole day of drafts)."""
996
- try:
997
- p = Path(store_path())
998
- if not p.exists():
999
- base = os.environ.get("S4L_TMP_DIR") or "/tmp"
1000
- p = Path(base) / f"twitter_cycle_plan_{batch}.json"
1001
- return json.loads(p.read_text()).get("created_at") or None
1002
- except Exception:
1003
- return None
1004
-
1005
-
1006
- def _ts_before(a, b):
1007
- """True when iso timestamp a is strictly before b. Tolerates the two stamp
1008
- shapes in play ('...Z' from merge_review_queue, '+00:00' from time_iso)."""
1009
- try:
1010
- import datetime
1011
-
1012
- def parse(s):
1013
- return datetime.datetime.fromisoformat(str(s).replace("Z", "+00:00"))
1014
-
1015
- return parse(a) < parse(b)
1016
- except Exception:
1017
- return False
1018
-
1019
-
1020
- def _stale_for_plan(it, gen):
1021
- """A ledger item that predates the current plan generation refers to a DEAD
1022
- plan's numbering; its `n` must never match against the live plan."""
1023
- return bool(gen) and bool(it.get("ts")) and _ts_before(it.get("ts"), gen)
1024
-
1025
-
1026
- def _ledger_items_for_plan(batch, gen):
1027
- """Decided ledger items that belong to the CURRENT plan generation."""
1028
- return [
1029
- it
1030
- for it in read_approved_queue()["items"]
1031
- if it.get("batch") == batch
1032
- and it.get("status") in ("queued", "posting", "posted", "failed", "rejected")
1033
- and not _stale_for_plan(it, gen)
1034
- ]
1035
-
1036
-
1037
981
  def review_drafts(plan, batch="review-queue"):
1038
982
  """Flatten a plan into the card model: only UNDECIDED candidates. A card that's
1039
983
  posted, terminal (rejected/dead), or already approved is a settled decision and
1040
984
  must never be re-presented for review (approved ones proceed to post).
1041
985
 
1042
- Also excludes cards with ANY durable decision (approved, edited, rejected, or a
1043
- decided-but-failed post) via _ledger_items_for_plan() below. approve/reject/edit
1044
- each write a durable local record via store_stamp_decision() the INSTANT the
1045
- user clicks (see s4l_menubar.py::_on_card_decision), so a
1046
- decided card never re-presents even if the loopback (Claude Desktop) is down
1047
- when the decision's plan-flag write is attempted. The main plan's
1048
- `approved`/`terminal`/`posted` flags are only stamped once the loopback write
1049
- lands, so without this a card the user just decided would re-present (the exact
1050
- "I already decided these" bug)."""
1051
- gen = (plan or {}).get("created_at") or _plan_generation(batch)
1052
- items = _ledger_items_for_plan(batch, gen)
1053
- settled_ids = {
1054
- it.get("candidate_id") for it in items if it.get("candidate_id") is not None
1055
- }
1056
- settled_ns = {it.get("n") for it in items if it.get("candidate_id") is None}
986
+ Decision durability lives in the store itself: approve/reject/edit each
987
+ write approved/terminal + the decision payload via store_stamp_decision()
988
+ the INSTANT the user clicks (see s4l_menubar.py::_on_card_decision), under
989
+ the store lock, so candidate_state() alone decides what re-presents. The
990
+ legacy approved-queue.json ledger cross-check that used to live here was
991
+ removed 2026-07-17 along with the rest of that third state store."""
1057
992
  out = []
1058
993
  for i, c in enumerate(((plan or {}).get("candidates") or [])):
1059
994
  # Only awaiting_review rows become cards. This also skips post_failed
1060
995
  # rows (decided-but-failed is settled per the docstring above).
1061
996
  if candidate_state(c) != "awaiting_review":
1062
997
  continue
1063
- cid = c.get("candidate_id")
1064
- if cid is not None and cid in settled_ids:
1065
- continue
1066
- if (i + 1) in settled_ns:
1067
- continue
1068
998
  out.append(
1069
999
  {
1070
1000
  "n": i + 1, # 1-based, matches post_drafts numbering
@@ -1132,35 +1062,13 @@ def review_drafts(plan, batch="review-queue"):
1132
1062
  return out
1133
1063
 
1134
1064
 
1135
- # ---- durable approved-card queue ------------------------------------------
1136
- # Card approvals MUST survive a menu bar / Claude restart. The in-memory post
1137
- # queue does not: a restart strands every approved-but-unposted card, which then
1138
- # re-presents for approval (the system had no record the user already approved
1139
- # it). This file is the durable record, owned SOLELY by the menu bar — persisting
1140
- # the approval in the main plan instead would race with the autopilot, which
1141
- # rewrites that plan continuously and would silently drop the flag. Status flow:
1142
- # queued -> posting -> posted | failed. review_drafts() excludes queued/posting
1143
- # so an approved card is never re-shown while it drains; a restart re-enqueues
1144
- # queued/posting items instead of re-presenting them.
1145
- APPROVED_QUEUE = "approved-queue.json"
1146
-
1147
-
1148
- def read_approved_queue():
1149
- d = read_json(APPROVED_QUEUE)
1150
- if not isinstance(d, dict) or not isinstance(d.get("items"), list):
1151
- return {"items": []}
1152
- return d
1153
-
1154
-
1155
- def _write_approved_queue(d):
1156
- try:
1157
- p = Path(state_dir()) / APPROVED_QUEUE
1158
- p.parent.mkdir(parents=True, exist_ok=True)
1159
- tmp = p.with_suffix(".json.tmp")
1160
- tmp.write_text(json.dumps(d))
1161
- os.replace(str(tmp), str(p)) # atomic: a crash never leaves a half file
1162
- except Exception:
1163
- pass
1065
+ # ---- durable approved-card queue: REMOVED (2026-07-17) ---------------------
1066
+ # approved-queue.json was a second local ledger from before the review store
1067
+ # stamped decisions durably itself (store_stamp_decision, under the store
1068
+ # lock). Once decisions lived in the store, nothing appended to the ledger,
1069
+ # every status write was a no-op over a frozen item list, and its read lane
1070
+ # re-enqueued dead approvals forever. The review store is the ONLY local
1071
+ # decision record now; do not add a second one back.
1164
1072
 
1165
1073
 
1166
1074
  # ---- Engagement mode (2026-06-26, dual-flag 2026-06-29) -------------------
@@ -1422,22 +1330,10 @@ def toggle_lane(lane):
1422
1330
  return write_flags(f["personal_brand"], f["promotion"])
1423
1331
 
1424
1332
 
1425
- def approved_queue_set_status(batch, n, status, error=None):
1426
- with _approved_lock:
1427
- d = read_approved_queue()
1428
- changed = False
1429
- for it in d["items"]:
1430
- if it.get("batch") == batch and it.get("n") == n:
1431
- it.update(status=status, error=error, ts=time_iso())
1432
- changed = True
1433
- if changed:
1434
- _write_approved_queue(d)
1435
-
1436
-
1437
- # approved_queue_pending() REMOVED (2026-07-17): the legacy approved-queue.json
1438
- # resume lane was a third source of truth whose entries could never settle (the
1439
- # ledger stopped being written), so it re-enqueued dead approvals forever. The
1440
- # store lane (store_pending_posts) is the only resume source now.
1333
+ # approved_queue_set_status() / approved_queue_pending() REMOVED (2026-07-17):
1334
+ # the legacy approved-queue.json ledger is gone entirely — see the removal note
1335
+ # above read-approved-queue's old location. The store lane (store_stamp_decision
1336
+ # + store_pending_posts) is the only decision record and resume source now.
1441
1337
 
1442
1338
 
1443
1339
  def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.6",
3
+ "version": "1.7.6-rc.8",
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-rc.6",
3
+ "version": "1.7.6-rc.8",
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",
@@ -383,137 +383,156 @@ def main() -> int:
383
383
  # cycle process and only that process knows them.
384
384
 
385
385
  dst = store_path()
386
- existing = []
387
- plan_created_at = None
388
- if os.path.exists(dst):
389
- try:
390
- with open(dst) as f:
391
- prev = json.load(f)
392
- existing = prev.get("candidates") or []
393
- plan_created_at = prev.get("created_at")
394
- except Exception:
395
- existing = []
396
- plan_created_at = None
397
- # Absorb a REAL file at the legacy /tmp location (pre-upgrade store, or one
398
- # written by old code after a reboot) so no pending draft or decision flag
399
- # is lost, then ensure_store_symlink() below replaces it with the link.
400
- legacy = plan_path(REVIEW_QUEUE_ID)
401
- if os.path.exists(legacy) and not os.path.islink(legacy):
386
+ # ---- store lock ---------------------------------------------------
387
+ # Hold the SAME fcntl.flock the menubar's _store_update and the MCP's
388
+ # store_patch.py take, for the whole read->merge->write span. Without
389
+ # it this function was a last-writer-wins whole-file rewrite: a card
390
+ # decision stamped between our read (below) and _atomic_write was
391
+ # silently erased (the 2026-07-17 truth-loss family, including the
392
+ # resurrected sandbox approval that posted for real). The lock covers
393
+ # one bulk API call (_sync_with_backend); worst case a menubar decision
394
+ # write blocks for a few seconds once per cycle, which beats losing it.
395
+ import fcntl
396
+ _lk = open(dst + ".lock", "w")
397
+ fcntl.flock(_lk, fcntl.LOCK_EX)
398
+ try:
399
+ existing = []
400
+ plan_created_at = None
401
+ if os.path.exists(dst):
402
+ try:
403
+ with open(dst) as f:
404
+ prev = json.load(f)
405
+ existing = prev.get("candidates") or []
406
+ plan_created_at = prev.get("created_at")
407
+ except Exception:
408
+ existing = []
409
+ plan_created_at = None
410
+ # Absorb a REAL file at the legacy /tmp location (pre-upgrade store, or one
411
+ # written by old code after a reboot) so no pending draft or decision flag
412
+ # is lost, then ensure_store_symlink() below replaces it with the link.
413
+ legacy = plan_path(REVIEW_QUEUE_ID)
414
+ if os.path.exists(legacy) and not os.path.islink(legacy):
415
+ try:
416
+ with open(legacy) as f:
417
+ lp = json.load(f)
418
+ lc = lp.get("candidates") or []
419
+ have = {_dedup_key(c) for c in existing}
420
+ absorbed = [c for c in lc if _dedup_key(c) not in have]
421
+ existing.extend(absorbed)
422
+ plan_created_at = plan_created_at or lp.get("created_at")
423
+ if absorbed:
424
+ print(
425
+ f"[merge_review_queue] absorbed {len(absorbed)} candidate(s) "
426
+ "from legacy /tmp plan into the durable store",
427
+ file=sys.stderr,
428
+ )
429
+ except Exception as e:
430
+ print(f"[merge_review_queue] legacy plan absorb failed: {e}", file=sys.stderr)
431
+ # Generation stamp: set ONLY when starting a fresh plan (the /tmp plan dies
432
+ # on reboot/tmp-sweep and numbering restarts at 1). The menu bar's durable
433
+ # approved-queue ledger uses this to ignore decisions that belong to a dead
434
+ # plan generation; without it, stale (batch, n) entries silently swallow
435
+ # every new draft after a reset. An existing unstamped plan is left
436
+ # unstamped: back-stamping it "now" would invalidate live decisions.
437
+ if not existing and not plan_created_at:
438
+ plan_created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
439
+
440
+ seen = {_dedup_key(c) for c in existing}
441
+ added = 0
442
+ merged = list(existing)
443
+ for c in new_cands:
444
+ k = _dedup_key(c)
445
+ if k in seen:
446
+ continue
447
+ seen.add(k)
448
+ merged.append(c)
449
+ added += 1
450
+
451
+ stamped, pruned = _sync_with_backend(merged)
452
+ if stamped:
453
+ print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
454
+ if pruned:
455
+ print(
456
+ f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
457
+ "backend (expired/etc.) before they were reviewed",
458
+ file=sys.stderr,
459
+ )
460
+
461
+ plan_obj = {"candidates": merged}
462
+ if plan_created_at:
463
+ plan_obj["created_at"] = plan_created_at
464
+
465
+ # This is the actual delivery: if anything below throws, the cycle's drafts
466
+ # were computed but never reached the store the menu bar reads, and the
467
+ # wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
468
+ # stderr with `|| true`, so a crash here previously vanished into a local
469
+ # log nothing central reads — the exact blind spot that cost the 2026-07-08
470
+ # Karol investigation its root cause. Report it like any other handled
471
+ # pipeline failure (see twitter_post_plan.py's post-failure capture).
402
472
  try:
403
- with open(legacy) as f:
404
- lp = json.load(f)
405
- lc = lp.get("candidates") or []
406
- have = {_dedup_key(c) for c in existing}
407
- absorbed = [c for c in lc if _dedup_key(c) not in have]
408
- existing.extend(absorbed)
409
- plan_created_at = plan_created_at or lp.get("created_at")
410
- if absorbed:
411
- print(
412
- f"[merge_review_queue] absorbed {len(absorbed)} candidate(s) "
413
- "from legacy /tmp plan into the durable store",
414
- file=sys.stderr,
415
- )
473
+ _atomic_write(dst, plan_obj)
474
+ ensure_store_symlink()
475
+
476
+ # Refresh the review-request marker the menu bar polls. count = cards
477
+ # actually awaiting review (mirrors s4l_state.candidate_state()'s
478
+ # awaiting_review bucket): approved-unposted and post_failed rows are
479
+ # settled decisions, counting them inflated the badge and misled every
480
+ # human/agent reading the marker.
481
+ pending_count = len(
482
+ [
483
+ c
484
+ for c in merged
485
+ if not c.get("posted")
486
+ and not c.get("terminal")
487
+ and not c.get("post_failed")
488
+ and not c.get("approved")
489
+ ]
490
+ )
491
+ project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
492
+ _atomic_write(
493
+ review_request_path(),
494
+ {
495
+ "batch_id": REVIEW_QUEUE_ID,
496
+ "project": project,
497
+ "count": pending_count,
498
+ "plan_path": dst,
499
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
500
+ },
501
+ )
416
502
  except Exception as e:
417
- print(f"[merge_review_queue] legacy plan absorb failed: {e}", file=sys.stderr)
418
- # Generation stamp: set ONLY when starting a fresh plan (the /tmp plan dies
419
- # on reboot/tmp-sweep and numbering restarts at 1). The menu bar's durable
420
- # approved-queue ledger uses this to ignore decisions that belong to a dead
421
- # plan generation; without it, stale (batch, n) entries silently swallow
422
- # every new draft after a reset. An existing unstamped plan is left
423
- # unstamped: back-stamping it "now" would invalidate live decisions.
424
- if not existing and not plan_created_at:
425
- plan_created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
426
-
427
- seen = {_dedup_key(c) for c in existing}
428
- added = 0
429
- merged = list(existing)
430
- for c in new_cands:
431
- k = _dedup_key(c)
432
- if k in seen:
433
- continue
434
- seen.add(k)
435
- merged.append(c)
436
- added += 1
437
-
438
- stamped, pruned = _sync_with_backend(merged)
439
- if stamped:
440
- print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
441
- if pruned:
503
+ print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
504
+ try:
505
+ import sentry_init
506
+
507
+ sentry_init.init()
508
+ sentry_init.capture_message(
509
+ f"merge_review_queue delivery failed: {e}",
510
+ level="error",
511
+ tags={"component": "merge_review_queue", "added": str(added)},
512
+ extra={"plan_src": src},
513
+ )
514
+ sentry_init.flush(2.0)
515
+ except Exception:
516
+ pass
517
+ return 1
518
+
442
519
  print(
443
- f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
444
- "backend (expired/etc.) before they were reviewed",
520
+ f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
521
+ f"({pending_count} pending total) from {os.path.basename(src)}",
445
522
  file=sys.stderr,
446
523
  )
447
-
448
- plan_obj = {"candidates": merged}
449
- if plan_created_at:
450
- plan_obj["created_at"] = plan_created_at
451
-
452
- # This is the actual delivery: if anything below throws, the cycle's drafts
453
- # were computed but never reached the store the menu bar reads, and the
454
- # wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
455
- # stderr with `|| true`, so a crash here previously vanished into a local
456
- # log nothing central reads — the exact blind spot that cost the 2026-07-08
457
- # Karol investigation its root cause. Report it like any other handled
458
- # pipeline failure (see twitter_post_plan.py's post-failure capture).
459
- try:
460
- _atomic_write(dst, plan_obj)
461
- ensure_store_symlink()
462
-
463
- # Refresh the review-request marker the menu bar polls. count = cards
464
- # actually awaiting review (mirrors s4l_state.candidate_state()'s
465
- # awaiting_review bucket): approved-unposted and post_failed rows are
466
- # settled decisions, counting them inflated the badge and misled every
467
- # human/agent reading the marker.
468
- pending_count = len(
469
- [
470
- c
471
- for c in merged
472
- if not c.get("posted")
473
- and not c.get("terminal")
474
- and not c.get("post_failed")
475
- and not c.get("approved")
476
- ]
477
- )
478
- project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
479
- _atomic_write(
480
- review_request_path(),
481
- {
482
- "batch_id": REVIEW_QUEUE_ID,
483
- "project": project,
484
- "count": pending_count,
485
- "plan_path": dst,
486
- "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
487
- },
488
- )
489
- except Exception as e:
490
- print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
524
+ # Clean up the consumed batch plan so /tmp doesn't fill with orphans.
491
525
  try:
492
- import sentry_init
493
-
494
- sentry_init.init()
495
- sentry_init.capture_message(
496
- f"merge_review_queue delivery failed: {e}",
497
- level="error",
498
- tags={"component": "merge_review_queue", "added": str(added)},
499
- extra={"plan_src": src},
500
- )
501
- sentry_init.flush(2.0)
526
+ os.remove(src)
502
527
  except Exception:
503
528
  pass
504
- return 1
505
-
506
- print(
507
- f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
508
- f"({pending_count} pending total) from {os.path.basename(src)}",
509
- file=sys.stderr,
510
- )
511
- # Clean up the consumed batch plan so /tmp doesn't fill with orphans.
512
- try:
513
- os.remove(src)
514
- except Exception:
515
- pass
516
- return 0
529
+ return 0
530
+ finally:
531
+ try:
532
+ fcntl.flock(_lk, fcntl.LOCK_UN)
533
+ except Exception:
534
+ pass
535
+ _lk.close()
517
536
 
518
537
 
519
538
  if __name__ == "__main__":
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Locked field-patcher for the review-queue store.
3
+
4
+ The review store (review-queue.json) has three writers: the menubar
5
+ (s4l_state._store_update), merge_review_queue.py, and the MCP server. The
6
+ first two hold an fcntl.flock on `<store>.lock` around their read-modify-write;
7
+ the MCP server is Node, which has no native flock, so it routes its store
8
+ writes through THIS script instead of writing the file itself. That closes the
9
+ last unlocked writer (the last-writer-wins race that erased posted stamps on
10
+ 2026-07-17 — six live replies rendered as duplicate_thread_pre_post kills).
11
+
12
+ Protocol-compatible with s4l_state._store_update: same lock file, LOCK_EX for
13
+ the whole read-mutate-replace span, atomic os.replace. fcntl (not an O_EXCL
14
+ lockfile) on purpose: the kernel drops the lock when the holder dies, so there
15
+ is no stale-lock stealing logic to get wrong.
16
+
17
+ Usage: store_patch.py <patches.json> (or '-' to read the JSON from stdin)
18
+
19
+ Input shape:
20
+ {"patches": [
21
+ {"candidate_id": 123, # match ALL rows with this id (ids are NOT
22
+ # unique: sandbox reruns/variant drafts
23
+ # append sibling rows; a stamp that hits
24
+ # only one sibling leaves a drainable twin)
25
+ "n": 4, # 1-based index fallback when candidate_id
26
+ # is absent/None (legacy rows)
27
+ "set": {"approved": true}, # fields to assign
28
+ "unset": ["discard_reason"]} # fields to delete
29
+ ]}
30
+
31
+ Merge rules (mirror mergeApprovedStampsIntoStore):
32
+ - posted is sticky: a patch may set posted=true, but `set.terminal=true` is
33
+ ignored on a row whose posted is already true, and posted is never unset.
34
+ Prints {"ok": true, "patched": N} on stdout. Exit 0 even when N=0 (a patch
35
+ matching nothing is not an error; the row may have been absorbed elsewhere).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import fcntl
41
+ import json
42
+ import os
43
+ import sys
44
+
45
+
46
+ def store_path() -> str:
47
+ state_dir = os.environ.get("S4L_STATE_DIR") or os.path.join(
48
+ os.path.expanduser("~"), ".social-autoposter-mcp"
49
+ )
50
+ return os.path.join(state_dir, "review-queue.json")
51
+
52
+
53
+ def _match_rows(cands: list, patch: dict) -> list:
54
+ cid = patch.get("candidate_id")
55
+ if cid is not None:
56
+ rows = [c for c in cands if c.get("candidate_id") == cid]
57
+ if rows:
58
+ return rows
59
+ n = patch.get("n")
60
+ if isinstance(n, int) and 1 <= n <= len(cands):
61
+ return [cands[n - 1]]
62
+ return []
63
+
64
+
65
+ def apply_patches(data: dict, patches: list) -> int:
66
+ cands = data.get("candidates") or []
67
+ patched = 0
68
+ for p in patches:
69
+ for c in _match_rows(cands, p):
70
+ sets = dict(p.get("set") or {})
71
+ if c.get("posted") is True:
72
+ sets.pop("terminal", None)
73
+ sets.pop("terminal_reason", None)
74
+ for k, v in sets.items():
75
+ if k == "posted" and v is not True and c.get("posted") is True:
76
+ continue # posted is sticky
77
+ c[k] = v
78
+ for k in p.get("unset") or []:
79
+ if k == "posted":
80
+ continue
81
+ c.pop(k, None)
82
+ patched += 1
83
+ return patched
84
+
85
+
86
+ def main() -> int:
87
+ src = sys.argv[1] if len(sys.argv) > 1 else "-"
88
+ raw = sys.stdin.read() if src == "-" else open(src).read()
89
+ patches = (json.loads(raw) or {}).get("patches") or []
90
+ sp = store_path()
91
+ with open(sp + ".lock", "w") as lk:
92
+ fcntl.flock(lk, fcntl.LOCK_EX)
93
+ try:
94
+ try:
95
+ with open(sp) as f:
96
+ data = json.load(f)
97
+ except Exception:
98
+ data = {"candidates": []}
99
+ patched = apply_patches(data, patches)
100
+ tmp = f"{sp}.tmp.{os.getpid()}"
101
+ with open(tmp, "w") as f:
102
+ json.dump(data, f, indent=2)
103
+ os.replace(tmp, sp)
104
+ finally:
105
+ fcntl.flock(lk, fcntl.LOCK_UN)
106
+ print(json.dumps({"ok": True, "patched": patched}))
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
@@ -33,6 +33,30 @@ log "=== archive-old-logs starting (DAYS=$DAYS) ==="
33
33
  find "$LOG_DIR" -maxdepth 1 -type f -mtime +"$DAYS" ! -name "$(basename "$RUN_LOG")" -print0 \
34
34
  | xargs -0 -I{} mv {} "$ARCHIVE_DIR/" 2>&1 | tee -a "$RUN_LOG" >/dev/null || true
35
35
 
36
+ # --- Size-based rotation for live launchd-*.log files (2026-07-17) -----------
37
+ # launchd holds these fds open and appends forever, so the mtime sweep above
38
+ # never touches them: their mtime is always fresh. Found in the wild at 1.9GB
39
+ # (launchd-linkedin-stdout.log) and 694MB (launchd-engage-twitter-stdout.log),
40
+ # which made "grep the logs" surface months-stale content as if it were
41
+ # current (the 2026-07-17 Neon-hostname false alarm).
42
+ # Copy-truncate: gzip the current content into logs-archive with a timestamp,
43
+ # then truncate IN PLACE (same inode) so the launchd fd keeps appending.
44
+ # Lines appended during the gzip window are lost on truncate; acceptable for
45
+ # logs, and the window is seconds. Threshold tunable via ROTATE_MAX_MB.
46
+ ROTATE_MAX_MB="${ROTATE_MAX_MB:-10}"
47
+ find "$LOG_DIR" -maxdepth 1 -type f -name "launchd-*.log" -size +"${ROTATE_MAX_MB}M" -print0 \
48
+ | while IFS= read -r -d '' f; do
49
+ base=$(basename "$f" .log)
50
+ dest="$ARCHIVE_DIR/${base}-$(date +%Y%m%d-%H%M%S).log.gz"
51
+ if gzip -c "$f" > "$dest" 2>>"$RUN_LOG"; then
52
+ : > "$f"
53
+ log "rotated $(basename "$f") -> $(basename "$dest") ($(du -h "$dest" | cut -f1) compressed)"
54
+ else
55
+ rm -f "$dest"
56
+ log "WARN: rotate failed for $f (left untouched)"
57
+ fi
58
+ done
59
+
36
60
  remaining=$(find "$LOG_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
37
61
  archived=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
38
62