social-autoposter 1.6.134 → 1.6.136

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
@@ -2607,66 +2607,63 @@ async function ensureMemorySnapshotInstalled() {
2607
2607
  return { ok: false, detail: e?.message || String(e) };
2608
2608
  }
2609
2609
  }
2610
- // The CURRENT Claude account's session id, from config.json's per-session
2611
- // `dxt:allowlistLastUpdated:<id>` keys (most-recent = live session; matches this
2612
- // MCP's own start time). Lets us read the schedule for the ACTUAL logged-in
2613
- // account instead of guessing. Mirrors s4l_menubar.py::_active_session_id.
2614
- function activeSessionId() {
2615
- try {
2616
- const p = path.join(os.homedir(), "Library", "Application Support", "Claude", "config.json");
2617
- const d = JSON.parse(fs.readFileSync(p, "utf-8"));
2618
- let bestId = null;
2619
- let bestTs = "";
2620
- for (const [k, v] of Object.entries(d)) {
2621
- if (k.startsWith("dxt:allowlistLastUpdated:") && typeof v === "string" && v > bestTs) {
2622
- bestTs = v;
2623
- bestId = k.slice("dxt:allowlistLastUpdated:".length);
2624
- }
2625
- }
2626
- return bestId;
2627
- }
2628
- catch {
2629
- return null;
2630
- }
2631
- }
2632
- // The ACTUAL schedule state for the current account (read, not inferred from
2633
- // firing): 'missing' | 'disabled' | 'ok' | 'unknown'. Drives the dashboard's
2634
- // "Set up draft schedule" button. Mirrors s4l_menubar.py::_schedule_state.
2610
+ // Is the draft schedule registered AND running for the LIVE account?
2611
+ // 'ok' — worker tasks present+enabled and FIRING (lastRunAt within
2612
+ // SCHEDULE_FIRING_MS) the host is actively running them.
2613
+ // 'disabled' present but a worker task is disabled.
2614
+ // 'missing' — not firing anywhere (orphaned / not registered for the live
2615
+ // account) -> dashboard offers "Set up draft schedule".
2616
+ // The live account is identified by the registry the host is actually FIRING
2617
+ // (freshest lastRunAt — only the active account's scheduler advances it), NOT a
2618
+ // session id (which churns on every Claude restart and mis-read "missing" while
2619
+ // the tasks were firing). Mirrors s4l_menubar.py::_schedule_state.
2620
+ const SCHEDULE_FIRING_MS = 420_000; // 7 min; tolerates throttle + restart gaps
2635
2621
  function scheduleState() {
2636
- const sid = activeSessionId();
2637
- if (!sid)
2638
- return "unknown";
2639
- let regPath = null;
2622
+ let newestMs = null;
2623
+ let newestEnabled = false;
2624
+ let anyPresent = false;
2625
+ let anyEnabled = false;
2640
2626
  try {
2641
2627
  const base = path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code-sessions");
2642
2628
  for (const ws of fs.readdirSync(base)) {
2643
- const cand = path.join(base, ws, sid, "scheduled-tasks.json");
2644
- if (fs.existsSync(cand)) {
2645
- regPath = cand;
2646
- break;
2629
+ for (const inner of fs.readdirSync(path.join(base, ws))) {
2630
+ const reg = path.join(base, ws, inner, "scheduled-tasks.json");
2631
+ let d;
2632
+ try {
2633
+ d = JSON.parse(fs.readFileSync(reg, "utf-8"));
2634
+ }
2635
+ catch {
2636
+ continue;
2637
+ }
2638
+ const byId = {};
2639
+ for (const t of d.scheduledTasks || [])
2640
+ byId[t.id] = t;
2641
+ const recs = QUEUE_WORKERS.map((s) => byId[s.taskId]);
2642
+ if (recs.some((r) => !r))
2643
+ continue;
2644
+ anyPresent = true;
2645
+ const enabled = recs.every((r) => r.enabled);
2646
+ anyEnabled = anyEnabled || enabled;
2647
+ const epochs = recs
2648
+ .map((r) => Date.parse(r.lastRunAt || ""))
2649
+ .filter((n) => !Number.isNaN(n));
2650
+ const e = epochs.length ? Math.max(...epochs) : null;
2651
+ if (e !== null && (newestMs === null || e > newestMs)) {
2652
+ newestMs = e;
2653
+ newestEnabled = enabled;
2654
+ }
2647
2655
  }
2648
2656
  }
2649
2657
  }
2650
2658
  catch {
2651
- /* base missing */
2652
- }
2653
- if (!regPath)
2654
- return "missing";
2655
- try {
2656
- const d = JSON.parse(fs.readFileSync(regPath, "utf-8"));
2657
- const byId = {};
2658
- for (const t of d.scheduledTasks || [])
2659
- byId[t.id] = t;
2660
- const recs = QUEUE_WORKERS.map((s) => byId[s.taskId]);
2661
- if (recs.some((r) => !r))
2662
- return "missing";
2663
- if (recs.some((r) => !r.enabled))
2664
- return "disabled";
2665
- return "ok";
2659
+ /* base missing -> treat as missing */
2666
2660
  }
2667
- catch {
2668
- return "missing";
2661
+ if (newestMs !== null && Date.now() - newestMs <= SCHEDULE_FIRING_MS) {
2662
+ return newestEnabled ? "ok" : "disabled";
2669
2663
  }
2664
+ if (anyPresent && !anyEnabled)
2665
+ return "disabled";
2666
+ return "missing";
2670
2667
  }
2671
2668
  // Assemble everything the panel needs in one shot (projects + X + autopilot +
2672
2669
  // version). Resilient: any probe that throws degrades to a safe default rather
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.134",
3
- "installedAt": "2026-06-29T02:17:51.544Z"
2
+ "version": "1.6.136",
3
+ "installedAt": "2026-06-29T04:09:39.530Z"
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.134",
5
+ "version": "1.6.136",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "The disclaimer above is generic Claude boilerplate. S4L 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 end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
@@ -133,6 +133,12 @@ REARM_PROMPT = (
133
133
  # scheduler's per-minute cadence + a slow claim.
134
134
  AUTOPILOT_STALL_SECONDS = 180
135
135
 
136
+ # A worker task whose lastRunAt is within this many seconds is "firing" — the host
137
+ # scheduler runs them every minute, so a fresh stamp means the live account's
138
+ # schedule is active. 7 min tolerates host throttling + a restart gap without
139
+ # false "not scheduled". Used by _schedule_state.
140
+ FIRING_WINDOW = 420
141
+
136
142
 
137
143
  def _glyph(status):
138
144
  return GLYPH.get(status, "·")
@@ -236,9 +242,14 @@ class S4LMenuBar(rumps.App):
236
242
  # indicator + the "Please update now" menu item.
237
243
  self._update_available = False
238
244
  self._latest_version = None
239
- self._upd_timer = rumps.Timer(self._check_update, 60) # every 1 min
245
+ # Poll every 15 min, NOT every 60s. _check_update hits GitHub's
246
+ # releases/latest UNAUTHENTICATED (60 req/hr per IP); a 60s poll = 60/hr,
247
+ # right at the cap, so it got 403-rate-limited and silently stopped
248
+ # detecting updates (the "update available" badge never showed). 15 min =
249
+ # 4/hr leaves ample headroom; updates aren't urgent enough to poll faster.
250
+ self._upd_timer = rumps.Timer(self._check_update, 900)
240
251
  self._upd_timer.start()
241
- self._check_update(None)
252
+ self._check_update(None) # one check at launch (badge appears ~immediately)
242
253
  # One-shot self-heal: if the autopilot scheduled tasks are running in the
243
254
  # wrong folder (or the deprecated single autopilot task still exists),
244
255
  # relocate them to ~/.s4l-worker so their once-a-minute runs stop polluting
@@ -351,76 +362,80 @@ class S4LMenuBar(rumps.App):
351
362
  do NOT auto-type (focus/timing flaky) and do NOT write the registry directly
352
363
  (can't reliably target a just-switched-into account)."""
353
364
  copied = self._copy_to_clipboard(REARM_PROMPT)
365
+ # Show the modal BEFORE opening Claude. If we raise Claude first, the NSAlert
366
+ # renders BEHIND it (invisible) while still blocking the main thread, which
367
+ # is what made the menu look frozen/greyed with no visible dialog. Showing
368
+ # it first keeps it frontmost; we open Claude after the user dismisses it.
369
+ # (_notify's osascript notification silently no-ops from this bundle-less
370
+ # launchd process, so a modal is the only reliable feedback.)
371
+ msg = (
372
+ "The setup prompt is copied to your clipboard.\n\nClick OK, then click into "
373
+ "the Claude chat, paste it (Cmd+V), and press Enter — that schedules the "
374
+ "draft tasks for this account."
375
+ if copied
376
+ else "Click OK, then open Claude and ask it to set up the draft schedule for this account."
377
+ )
378
+ try:
379
+ rumps.alert(title="Set up the draft schedule", message=msg, ok="OK")
380
+ except Exception:
381
+ self._notify("S4L · setup prompt copied" if copied else "S4L",
382
+ "Paste the prompt into Claude (Cmd+V) and press Enter.")
354
383
  self._open_claude()
355
- if copied:
356
- self._notify(
357
- "S4L · setup prompt copied",
358
- "Paste it into Claude (⌘V) and press Enter to schedule the draft "
359
- "tasks for this account.",
360
- )
361
- else:
362
- self._notify(
363
- "S4L",
364
- "Open Claude and ask it to set up the draft schedule for this account.",
365
- )
366
384
 
367
- # ---- schedule-state detection (reads the ACTUAL schedule, reliably) ----
368
- def _active_session_id(self):
369
- """The CURRENT account's session id, from config.json's per-session
370
- `dxt:allowlistLastUpdated:<id>` keys the most-recently-updated one is the
371
- live session (it matches the MCP's own start time to the ms). This is what
372
- lets us read the schedule for the ACTUAL logged-in account instead of
373
- guessing a folder by mtime. None if undeterminable."""
385
+ # ---- schedule-state detection ----------------------------------------
386
+ # Identify the LIVE account's registry by which one the host is actually
387
+ # FIRING (freshest lastRunAt only the active account's scheduler advances
388
+ # it), then read THAT registry's enabled state. This is robust across the
389
+ # session-id churn that restarts cause; the old "active session = newest
390
+ # config.json allowlist key" heuristic mis-reported "missing" after a restart
391
+ # even while the tasks were firing.
392
+ @staticmethod
393
+ def _iso_to_epoch(s):
394
+ if not s:
395
+ return None
374
396
  try:
375
- p = os.path.join(os.path.expanduser("~"), "Library", "Application Support",
376
- "Claude", "config.json")
377
- with open(p) as fh:
378
- d = json.load(fh)
379
- best_id, best_ts = None, ""
380
- for k, v in d.items():
381
- if k.startswith("dxt:allowlistLastUpdated:") and isinstance(v, str):
382
- if v > best_ts: # ISO-8601 sorts lexically by time
383
- best_ts, best_id = v, k.split(":", 2)[2]
384
- return best_id
397
+ import calendar
398
+ return calendar.timegm(
399
+ time.strptime(str(s).strip().rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S")
400
+ )
385
401
  except Exception:
386
402
  return None
387
403
 
388
- def _active_session_registry_path(self):
389
- """Path to the current account's scheduled-tasks.json (claude-code-sessions/
390
- <workspace>/<active-session>/...). None if the active session can't be found
391
- or its dir doesn't exist yet (brand-new account -> schedule absent)."""
392
- sid = self._active_session_id()
393
- if not sid:
394
- return None
395
- hits = glob.glob(os.path.join(
396
- os.path.expanduser("~"), "Library", "Application Support", "Claude",
397
- "claude-code-sessions", "*", sid, "scheduled-tasks.json"))
398
- return hits[0] if hits else None
399
-
400
404
  def _schedule_state(self):
401
- """Read the ACTUAL schedule for the current account (not inferred from
402
- whether it fired 'not fired' != 'not scheduled'):
403
- 'missing' no registry / a worker task absent for this account
404
- 'disabled' — both present but at least one enabled=false
405
- 'ok' both worker tasks present and enabled
406
- 'unknown' — couldn't determine the active session (degrade gracefully)"""
407
- if not self._active_session_id():
408
- return "unknown"
409
- path = self._active_session_registry_path()
410
- if not path:
411
- return "missing"
412
- try:
413
- with open(path) as fh:
414
- d = json.load(fh)
415
- except Exception:
416
- return "missing"
417
- by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
418
- recs = [by_id.get(tid) for tid in WORKER_TASK_IDS]
419
- if any(r is None for r in recs):
420
- return "missing"
421
- if any(not r.get("enabled") for r in recs):
405
+ """Is the draft schedule registered AND running for the live account?
406
+ 'ok' — worker tasks present+enabled and FIRING (lastRunAt within
407
+ FIRING_WINDOW) the host is actively running them.
408
+ 'disabled' — present but a worker task is disabled.
409
+ 'missing' not firing anywhere (orphaned / not registered for the live
410
+ account) -> offer re-arm.
411
+ The active account is identified by the freshest-firing registry, NOT a
412
+ session id (which churns on restart)."""
413
+ newest_epoch, newest_enabled = None, False
414
+ any_present, any_enabled = False, False
415
+ for f in glob.glob(SCHED_REGISTRY_GLOB):
416
+ try:
417
+ with open(f) as fh:
418
+ d = json.load(fh)
419
+ except Exception:
420
+ continue
421
+ by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
422
+ recs = [by_id.get(tid) for tid in WORKER_TASK_IDS]
423
+ if any(r is None for r in recs):
424
+ continue
425
+ any_present = True
426
+ enabled = all(r.get("enabled") for r in recs)
427
+ any_enabled = any_enabled or enabled
428
+ epochs = [self._iso_to_epoch(r.get("lastRunAt")) for r in recs]
429
+ e = max([x for x in epochs if x is not None], default=None)
430
+ if e is not None and (newest_epoch is None or e > newest_epoch):
431
+ newest_epoch, newest_enabled = e, enabled
432
+ # Firing recently => the live account's schedule is active and healthy.
433
+ if newest_epoch is not None and (time.time() - newest_epoch) <= FIRING_WINDOW:
434
+ return "ok" if newest_enabled else "disabled"
435
+ # Not firing anywhere. Registered-but-disabled => disabled; else missing.
436
+ if any_present and not any_enabled:
422
437
  return "disabled"
423
- return "ok"
438
+ return "missing"
424
439
 
425
440
  # ---- autopilot liveness (the false-green fix) -------------------------
426
441
  def _autopilot_stalled(self):
@@ -998,21 +1013,17 @@ class S4LMenuBar(rumps.App):
998
1013
  blocker = (ob or {}).get("current_blocker")
999
1014
  blocker_code = (blocker or {}).get("code")
1000
1015
  # --- Autopilot health (only meaningful once setup is complete) --------
1001
- # PRIMARY signal: read the ACTUAL schedule for the CURRENT account
1002
- # (missing/disabled/ok) reliable, NOT inferred from whether it fired
1003
- # ("not fired" != "not scheduled"). SECONDARY: if scheduled+enabled but
1004
- # still not draining, work out why (rate-limit vs other).
1016
+ # SINGLE signal: is the draft schedule registered AND firing for the live
1017
+ # account (schedule_state)? 'ok' = the host is running the tasks -> healthy,
1018
+ # NO warning (even if no draft has drained yet that's just an empty queue
1019
+ # between cycles, not a setup problem). 'missing'/'disabled' = not running
1020
+ # for this account -> show re-arm. We deliberately do NOT drive the menu off
1021
+ # the drain-status latch anymore: it stayed stale after recovery and made a
1022
+ # firing, healthy autopilot look "not set up".
1005
1023
  schedule_state = self._schedule_state() if setup_complete else "ok"
1006
- stalled = setup_complete and self._autopilot_stalled()
1007
1024
  self._schedule_state_cache = schedule_state
1008
- # Stall reason only matters when the schedule IS on but drafts still aren't
1009
- # draining (rate-limit / other) not when it's simply unscheduled.
1010
- self._stall_reason_info = (
1011
- self._stall_reason() if (stalled and schedule_state in ("ok", "unknown")) else ("", "")
1012
- )
1013
- # Any non-healthy state drops the stale "drafting" spinner so the ⚠ in the
1014
- # title isn't masked (see _poll_activity).
1015
- attention = setup_complete and (schedule_state in ("missing", "disabled") or stalled)
1025
+ attention = setup_complete and schedule_state in ("missing", "disabled")
1026
+ # Drop the stale "drafting" spinner while we need attention so the ⚠ shows.
1016
1027
  self._stalled = attention
1017
1028
 
1018
1029
  # Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
@@ -1026,32 +1037,19 @@ class S4LMenuBar(rumps.App):
1026
1037
  blocker.get("message", "Setup is blocked"),
1027
1038
  )
1028
1039
  self._last_blocker_code = blocker_code
1029
- # Notify once per episode, naming the actual problem + the right action.
1040
+ # Notify once per episode (the draft schedule isn't running for this account).
1030
1041
  if attention and not self._stall_notified:
1031
- kind, detail = self._stall_reason_info
1032
- if schedule_state == "missing":
1033
- self._notify(
1034
- "S4L draft autopilot not scheduled",
1035
- "No draft tasks are scheduled on this Claude account (switching "
1036
- "accounts clears them). Open the S4L menu → “Set up draft schedule”.",
1037
- )
1038
- elif schedule_state == "disabled":
1042
+ if schedule_state == "disabled":
1039
1043
  self._notify(
1040
1044
  "S4L draft tasks disabled",
1041
1045
  "The draft tasks are scheduled but disabled. Open the S4L menu → "
1042
1046
  "“Set up draft schedule” to re-enable.",
1043
1047
  )
1044
- elif kind == "rate_limited":
1045
- self._notify(
1046
- "S4L autopilot paused",
1047
- "Claude usage limit reached" + (f" ({detail})" if detail else "")
1048
- + ". Drafting resumes at reset, or sign into an account with quota.",
1049
- )
1050
1048
  else:
1051
1049
  self._notify(
1052
- "S4L autopilot stalled",
1053
- "Drafts aren't being produced though the schedule is on. Open the "
1054
- "S4L menu for options.",
1050
+ "S4L draft autopilot not scheduled",
1051
+ "No draft tasks are running on this Claude account (switching "
1052
+ "accounts clears them). Open the S4L menu “Set up draft schedule”.",
1055
1053
  )
1056
1054
  self._stall_notified = True
1057
1055
  elif not attention:
@@ -1338,29 +1336,16 @@ class S4LMenuBar(rumps.App):
1338
1336
  items.append(header)
1339
1337
  items.append(rumps.separator)
1340
1338
 
1341
- # Autopilot needs attention: surface the right action FIRST, above the
1342
- # normal state items. PRIMARY driver = the actual schedule state for THIS
1343
- # account (missing/disabled) that's what "Set up draft schedule" fixes
1344
- # (host create_scheduled_task). Only when the schedule IS on but drafts
1345
- # aren't draining do we fall to the stall-reason wording (rate-limit/other),
1346
- # where re-creating the schedule wouldn't help.
1339
+ # Attention = the draft schedule isn't running for THIS account (missing or
1340
+ # disabled). "Set up draft schedule" fixes it via host create_scheduled_task.
1341
+ # When the schedule IS firing (ok), attention is False and nothing shows here
1342
+ # a firing autopilot reads as healthy even if no draft has drained yet.
1347
1343
  if attention:
1348
- if schedule_state == "missing":
1349
- items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
1350
- items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
1351
- elif schedule_state == "disabled":
1344
+ if schedule_state == "disabled":
1352
1345
  items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
1353
- items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
1354
1346
  else:
1355
- kind, detail = self._stall_reason_info
1356
- if kind == "rate_limited":
1357
- items.append(self._label("⚠ Autopilot paused — Claude usage limit"))
1358
- if detail:
1359
- items.append(self._label(f" {detail}"))
1360
- items.append(self._label(" Resumes at reset, or switch Claude account"))
1361
- else:
1362
- items.append(self._label("⚠ Scheduled, but no drafts are being produced"))
1363
- items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
1347
+ items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
1348
+ items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
1364
1349
  items.append(rumps.separator)
1365
1350
 
1366
1351
  if not runtime_ready:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.134",
3
+ "version": "1.6.136",
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": "social-autoposter",
3
- "version": "1.6.134",
3
+ "version": "1.6.136",
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"