social-autoposter 1.6.135 → 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.135",
3
- "installedAt": "2026-06-29T03:45:00.392Z"
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.135",
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, "·")
@@ -356,92 +362,80 @@ class S4LMenuBar(rumps.App):
356
362
  do NOT auto-type (focus/timing flaky) and do NOT write the registry directly
357
363
  (can't reliably target a just-switched-into account)."""
358
364
  copied = self._copy_to_clipboard(REARM_PROMPT)
359
- self._open_claude()
360
- # Use a MODAL alert, not _notify: osascript `display notification` from this
361
- # bundle-less launchd menu-bar process silently no-ops (no notification
362
- # permission), so the user got zero feedback. rumps.alert is an NSAlert that
363
- # always shows and requires an explicit dismiss — unmissable.
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
+ )
364
378
  try:
365
- if copied:
366
- rumps.alert(
367
- title="Paste in Claude to finish",
368
- message=(
369
- "The setup prompt is copied to your clipboard.\n\n"
370
- "Click into the Claude chat, paste it (Cmd+V), and press Enter. "
371
- "That schedules the draft tasks for this account."
372
- ),
373
- ok="Got it",
374
- )
375
- else:
376
- rumps.alert(
377
- title="Set up the draft schedule",
378
- message="Open Claude and ask it to set up the draft schedule for this account.",
379
- ok="OK",
380
- )
379
+ rumps.alert(title="Set up the draft schedule", message=msg, ok="OK")
381
380
  except Exception:
382
- # Best-effort fallback if the modal can't show for some reason.
383
- self._notify(
384
- "S4L · setup prompt copied" if copied else "S4L",
385
- "Paste it into Claude (Cmd+V) and press Enter to schedule the draft tasks.",
386
- )
381
+ self._notify("S4L · setup prompt copied" if copied else "S4L",
382
+ "Paste the prompt into Claude (Cmd+V) and press Enter.")
383
+ self._open_claude()
387
384
 
388
- # ---- schedule-state detection (reads the ACTUAL schedule, reliably) ----
389
- def _active_session_id(self):
390
- """The CURRENT account's session id, from config.json's per-session
391
- `dxt:allowlistLastUpdated:<id>` keys the most-recently-updated one is the
392
- live session (it matches the MCP's own start time to the ms). This is what
393
- lets us read the schedule for the ACTUAL logged-in account instead of
394
- 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
395
396
  try:
396
- p = os.path.join(os.path.expanduser("~"), "Library", "Application Support",
397
- "Claude", "config.json")
398
- with open(p) as fh:
399
- d = json.load(fh)
400
- best_id, best_ts = None, ""
401
- for k, v in d.items():
402
- if k.startswith("dxt:allowlistLastUpdated:") and isinstance(v, str):
403
- if v > best_ts: # ISO-8601 sorts lexically by time
404
- best_ts, best_id = v, k.split(":", 2)[2]
405
- 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
+ )
406
401
  except Exception:
407
402
  return None
408
403
 
409
- def _active_session_registry_path(self):
410
- """Path to the current account's scheduled-tasks.json (claude-code-sessions/
411
- <workspace>/<active-session>/...). None if the active session can't be found
412
- or its dir doesn't exist yet (brand-new account -> schedule absent)."""
413
- sid = self._active_session_id()
414
- if not sid:
415
- return None
416
- hits = glob.glob(os.path.join(
417
- os.path.expanduser("~"), "Library", "Application Support", "Claude",
418
- "claude-code-sessions", "*", sid, "scheduled-tasks.json"))
419
- return hits[0] if hits else None
420
-
421
404
  def _schedule_state(self):
422
- """Read the ACTUAL schedule for the current account (not inferred from
423
- whether it fired 'not fired' != 'not scheduled'):
424
- 'missing' no registry / a worker task absent for this account
425
- 'disabled' — both present but at least one enabled=false
426
- 'ok' both worker tasks present and enabled
427
- 'unknown' — couldn't determine the active session (degrade gracefully)"""
428
- if not self._active_session_id():
429
- return "unknown"
430
- path = self._active_session_registry_path()
431
- if not path:
432
- return "missing"
433
- try:
434
- with open(path) as fh:
435
- d = json.load(fh)
436
- except Exception:
437
- return "missing"
438
- by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
439
- recs = [by_id.get(tid) for tid in WORKER_TASK_IDS]
440
- if any(r is None for r in recs):
441
- return "missing"
442
- 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:
443
437
  return "disabled"
444
- return "ok"
438
+ return "missing"
445
439
 
446
440
  # ---- autopilot liveness (the false-green fix) -------------------------
447
441
  def _autopilot_stalled(self):
@@ -1019,21 +1013,17 @@ class S4LMenuBar(rumps.App):
1019
1013
  blocker = (ob or {}).get("current_blocker")
1020
1014
  blocker_code = (blocker or {}).get("code")
1021
1015
  # --- Autopilot health (only meaningful once setup is complete) --------
1022
- # PRIMARY signal: read the ACTUAL schedule for the CURRENT account
1023
- # (missing/disabled/ok) reliable, NOT inferred from whether it fired
1024
- # ("not fired" != "not scheduled"). SECONDARY: if scheduled+enabled but
1025
- # 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".
1026
1023
  schedule_state = self._schedule_state() if setup_complete else "ok"
1027
- stalled = setup_complete and self._autopilot_stalled()
1028
1024
  self._schedule_state_cache = schedule_state
1029
- # Stall reason only matters when the schedule IS on but drafts still aren't
1030
- # draining (rate-limit / other) not when it's simply unscheduled.
1031
- self._stall_reason_info = (
1032
- self._stall_reason() if (stalled and schedule_state in ("ok", "unknown")) else ("", "")
1033
- )
1034
- # Any non-healthy state drops the stale "drafting" spinner so the ⚠ in the
1035
- # title isn't masked (see _poll_activity).
1036
- 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.
1037
1027
  self._stalled = attention
1038
1028
 
1039
1029
  # Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
@@ -1047,32 +1037,19 @@ class S4LMenuBar(rumps.App):
1047
1037
  blocker.get("message", "Setup is blocked"),
1048
1038
  )
1049
1039
  self._last_blocker_code = blocker_code
1050
- # 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).
1051
1041
  if attention and not self._stall_notified:
1052
- kind, detail = self._stall_reason_info
1053
- if schedule_state == "missing":
1054
- self._notify(
1055
- "S4L draft autopilot not scheduled",
1056
- "No draft tasks are scheduled on this Claude account (switching "
1057
- "accounts clears them). Open the S4L menu → “Set up draft schedule”.",
1058
- )
1059
- elif schedule_state == "disabled":
1042
+ if schedule_state == "disabled":
1060
1043
  self._notify(
1061
1044
  "S4L draft tasks disabled",
1062
1045
  "The draft tasks are scheduled but disabled. Open the S4L menu → "
1063
1046
  "“Set up draft schedule” to re-enable.",
1064
1047
  )
1065
- elif kind == "rate_limited":
1066
- self._notify(
1067
- "S4L autopilot paused",
1068
- "Claude usage limit reached" + (f" ({detail})" if detail else "")
1069
- + ". Drafting resumes at reset, or sign into an account with quota.",
1070
- )
1071
1048
  else:
1072
1049
  self._notify(
1073
- "S4L autopilot stalled",
1074
- "Drafts aren't being produced though the schedule is on. Open the "
1075
- "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”.",
1076
1053
  )
1077
1054
  self._stall_notified = True
1078
1055
  elif not attention:
@@ -1359,29 +1336,16 @@ class S4LMenuBar(rumps.App):
1359
1336
  items.append(header)
1360
1337
  items.append(rumps.separator)
1361
1338
 
1362
- # Autopilot needs attention: surface the right action FIRST, above the
1363
- # normal state items. PRIMARY driver = the actual schedule state for THIS
1364
- # account (missing/disabled) that's what "Set up draft schedule" fixes
1365
- # (host create_scheduled_task). Only when the schedule IS on but drafts
1366
- # aren't draining do we fall to the stall-reason wording (rate-limit/other),
1367
- # 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.
1368
1343
  if attention:
1369
- if schedule_state == "missing":
1370
- items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
1371
- items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
1372
- elif schedule_state == "disabled":
1344
+ if schedule_state == "disabled":
1373
1345
  items.append(self._label("⚠ Draft tasks are scheduled but disabled"))
1374
- items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
1375
1346
  else:
1376
- kind, detail = self._stall_reason_info
1377
- if kind == "rate_limited":
1378
- items.append(self._label("⚠ Autopilot paused — Claude usage limit"))
1379
- if detail:
1380
- items.append(self._label(f" {detail}"))
1381
- items.append(self._label(" Resumes at reset, or switch Claude account"))
1382
- else:
1383
- items.append(self._label("⚠ Scheduled, but no drafts are being produced"))
1384
- 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))
1385
1349
  items.append(rumps.separator)
1386
1350
 
1387
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.135",
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.135",
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"