social-autoposter 1.6.80 → 1.6.82

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
@@ -1424,6 +1424,96 @@ async function autopilotLoaded() {
1424
1424
  }
1425
1425
  return { autopilot_on, auto_update_on };
1426
1426
  }
1427
+ // ---- Autopilot prompt: keep the scheduled task's SKILL.md current ------------
1428
+ // The scheduled task's prompt (SKILL.md) is owned by the host and does NOT update
1429
+ // when the plugin updates. So a behavior change (the no-improvise / voice-inline /
1430
+ // stop-cleanly rules) would never reach an already-scheduled task. We close that
1431
+ // gap by REWRITING the task's SKILL.md on server boot when its embedded prompt
1432
+ // version is older than what this build ships. The host reads the file fresh on
1433
+ // each run, so the next fire picks up the new prompt — no host tool, no user
1434
+ // action. Bump AUTOPILOT_PROMPT_VERSION whenever the prompt below changes.
1435
+ const AUTOPILOT_PROMPT_VERSION = 1;
1436
+ const AUTOPILOT_PROMPT_MARKER = "saps_autopilot_prompt_version";
1437
+ function autopilotSkillPath() {
1438
+ const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
1439
+ return path.join(cfg, "scheduled-tasks", AUTOPILOT_TASK_ID, "SKILL.md");
1440
+ }
1441
+ // The canonical draft-only autopilot prompt: uses ONLY scan_candidates +
1442
+ // submit_drafts, drafts from the INLINE project_voices, improvises nothing, and
1443
+ // stops cleanly if its tools aren't available.
1444
+ function autopilotSkillMd(project) {
1445
+ const body = [
1446
+ `You are running the Social Autoposter draft-only autopilot for the project "${project}". ` +
1447
+ `Run ONE cycle, then stop. DRAFT-ONLY: you must NEVER post to X/Twitter — you only queue ` +
1448
+ `drafts for the user's approval. Use ONLY two tools — scan_candidates and submit_drafts — ` +
1449
+ `and IMPROVISE NOTHING ELSE.`,
1450
+ ``,
1451
+ `Steps:`,
1452
+ `1. Call scan_candidates with project "${project}". It long-polls: if it returns a "Scan in ` +
1453
+ `progress" status, call scan_candidates again (same args) and keep re-calling until it ` +
1454
+ `returns candidates. Never sleep or use background waits. If it returns no candidates, stop ` +
1455
+ `cleanly — nothing to do this cycle.`,
1456
+ `2. The result includes a project_voices field — the brand voice + guardrails for each ` +
1457
+ `candidate's project. Draft from project_voices[<matched_project>] (voice, description, ` +
1458
+ `differentiator, content_guardrails). NEVER read config files, call project_config, or run ` +
1459
+ `Bash/Read to find the voice — everything you need is already in the scan result.`,
1460
+ `3. For each candidate you judge genuinely worth engaging, write ONE on-brand reply (<=250 ` +
1461
+ `chars, match the thread's language, genuinely helpful — not spammy, no hard selling). Skip ` +
1462
+ `the rest.`,
1463
+ `4. Call submit_drafts with the batch_id from scan_candidates and a drafts array of ` +
1464
+ `{candidate_id, reply_text}. This queues them for the menu-bar approval UI. Do NOT call ` +
1465
+ `post_drafts — posting is the user's decision.`,
1466
+ ``,
1467
+ `HARD GUARD: if scan_candidates is NOT available (ToolSearch returns no matching tool — can ` +
1468
+ `happen when a run spawns before the extension's MCP server has reconnected), STOP immediately ` +
1469
+ `and report "S4L tools unavailable, skipping this cycle." Do NOT search the connector registry, ` +
1470
+ `do NOT call list_connectors, do NOT improvise any other tool.`,
1471
+ ].join("\n");
1472
+ return (`---\n` +
1473
+ `name: ${AUTOPILOT_TASK_ID}\n` +
1474
+ `description: Social Autoposter draft-only autopilot for project ${project} — scans X, drafts ` +
1475
+ `on-brand replies (voice inline), queues them for approval. Never posts.\n` +
1476
+ `---\n\n` +
1477
+ body +
1478
+ `\n\n<!-- ${AUTOPILOT_PROMPT_MARKER}: ${AUTOPILOT_PROMPT_VERSION} -->\n`);
1479
+ }
1480
+ // Read the project name back from an existing autopilot SKILL.md so a rewrite
1481
+ // keeps the same project. Null if it can't be determined (then leave it alone).
1482
+ function detectAutopilotProject(skillMd) {
1483
+ const m1 = /project[s]?\s+"([^"]+)"/i.exec(skillMd);
1484
+ if (m1)
1485
+ return m1[1];
1486
+ const m2 = /for project\s+([A-Za-z0-9_-]+)/i.exec(skillMd);
1487
+ if (m2)
1488
+ return m2[1];
1489
+ return null;
1490
+ }
1491
+ // Refresh the scheduled task's SKILL.md when this build ships a newer prompt than
1492
+ // what's on disk. Best-effort, synchronous, never throws. Only touches an EXISTING
1493
+ // task (onboarding creates the first one), only when stale, and only when the
1494
+ // project reads back, so we never write a wrong/blank project.
1495
+ function ensureAutopilotPromptCurrent() {
1496
+ try {
1497
+ const skillPath = autopilotSkillPath();
1498
+ if (!fs.existsSync(skillPath))
1499
+ return; // no scheduled task yet
1500
+ const cur = fs.readFileSync(skillPath, "utf-8");
1501
+ const m = new RegExp(`${AUTOPILOT_PROMPT_MARKER}:\\s*(\\d+)`).exec(cur);
1502
+ const curVer = m ? parseInt(m[1], 10) : 0;
1503
+ if (curVer >= AUTOPILOT_PROMPT_VERSION)
1504
+ return; // already current
1505
+ const project = detectAutopilotProject(cur);
1506
+ if (!project) {
1507
+ console.error(`[autopilot] prompt is stale (v${curVer}) but project unreadable from SKILL.md; leaving it`);
1508
+ return;
1509
+ }
1510
+ fs.writeFileSync(skillPath, autopilotSkillMd(project), "utf-8");
1511
+ console.error(`[autopilot] refreshed scheduled-task prompt -> v${AUTOPILOT_PROMPT_VERSION} (was v${curVer}), project=${project}`);
1512
+ }
1513
+ catch (e) {
1514
+ console.error(`[autopilot] ensureAutopilotPromptCurrent error: ${e?.message || e}`);
1515
+ }
1516
+ }
1427
1517
  // Assemble everything the panel needs in one shot (projects + X + autopilot +
1428
1518
  // version). Resilient: any probe that throws degrades to a safe default rather
1429
1519
  // than failing the whole snapshot.
@@ -2261,6 +2351,11 @@ async function main() {
2261
2351
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
2262
2352
  // the version first materialized at install). Synchronous + best-effort.
2263
2353
  ensurePipelineCurrent();
2354
+ // Same problem for the scheduled task's prompt: the host owns its SKILL.md and
2355
+ // a plugin update never touches it. Rewrite it on boot when this build ships a
2356
+ // newer prompt version, so behavior changes (no-improvise / voice-inline /
2357
+ // stop-cleanly) reach an already-scheduled task on its next fire. Best-effort.
2358
+ ensureAutopilotPromptCurrent();
2264
2359
  const transport = new StdioServerTransport();
2265
2360
  await server.connect(transport);
2266
2361
  console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.80",
3
- "installedAt": "2026-06-22T19:14:04.032Z"
2
+ "version": "1.6.82",
3
+ "installedAt": "2026-06-22T21:04:08.350Z"
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.80",
5
+ "version": "1.6.82",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
7
7
  "long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
@@ -47,7 +47,6 @@ SETUP_PROMPT = (
47
47
  "must interactively sign in or no product can be identified."
48
48
  )
49
49
  DRAFT_PROMPT = "Run a social-autoposter draft cycle and show me the drafts to review."
50
- POST_PROMPT = "Post my approved social-autoposter drafts."
51
50
  UPDATE_PROMPT = "Update social-autoposter to the latest version."
52
51
 
53
52
 
@@ -97,7 +96,13 @@ class S4LMenuBar(rumps.App):
97
96
  self._last_blocker_code = None
98
97
  self._sig = None # last rendered state signature; skip rebuild if unchanged
99
98
  self._review_active = False # a review-card sequence is on screen
100
- self._reviewed_batches = set() # batch_ids already handled this run
99
+ # Signature of the pending drafts last presented. We de-dup on the CONTENT
100
+ # of the pending set, NOT on the batch_id: the server intentionally reuses
101
+ # a constant batch_id ("review-queue") so a continuous autopilot's drafts
102
+ # accumulate into one queue. Keying de-dup on that constant suppressed every
103
+ # later batch for the life of this process (only a restart cleared it),
104
+ # which is exactly the "drafts queued but no cards" bug.
105
+ self._last_review_sig = None
101
106
  self._spin_i = 0
102
107
  self._spinner = None # fast rumps.Timer animating the title while busy
103
108
  # Reliable self-check of our own Accessibility (TCC) grant — this is the
@@ -177,9 +182,6 @@ class S4LMenuBar(rumps.App):
177
182
  def _draft(self, _=None):
178
183
  self._send_to_claude(DRAFT_PROMPT)
179
184
 
180
- def _post(self, _=None):
181
- self._send_to_claude(POST_PROMPT)
182
-
183
185
  def _update(self, _=None):
184
186
  self._send_to_claude(UPDATE_PROMPT)
185
187
 
@@ -313,8 +315,13 @@ class S4LMenuBar(rumps.App):
313
315
  act = st.read_activity()
314
316
  label = act.get("label") if act else None
315
317
  if label:
316
- self._spin_i = (self._spin_i + 1) % len(SPINNER)
317
- self.title = f"S4L {label} {SPINNER[self._spin_i]}"
318
+ # A "✓" label (e.g. "posted 3/10 ✓") is a momentary confirmation, not
319
+ # ongoing work show it without the spinner glyph so it reads as done.
320
+ if "✓" in label:
321
+ self.title = f"S4L {label}"
322
+ else:
323
+ self._spin_i = (self._spin_i + 1) % len(SPINNER)
324
+ self.title = f"S4L {label} {SPINNER[self._spin_i]}"
318
325
  return
319
326
  try:
320
327
  if self._spinner is not None:
@@ -391,24 +398,35 @@ class S4LMenuBar(rumps.App):
391
398
  if not req:
392
399
  return
393
400
  batch = req.get("batch_id")
394
- if not batch or batch in self._reviewed_batches:
401
+ if not batch:
395
402
  return
396
403
  plan = st.read_plan(req.get("plan_path") or "")
397
404
  drafts = st.review_drafts(plan)
398
405
  # Nothing left to review (empty, missing plan, or all already posted via
399
- # the chat surface) — mark handled and clear the signal.
406
+ # the chat surface) — clear the signal and reset the signature so a future
407
+ # batch is presented fresh.
400
408
  if not drafts:
401
- self._reviewed_batches.add(batch)
409
+ self._last_review_sig = None
402
410
  st.clear_review_request()
403
411
  return
412
+ # De-dup on the CONTENT of the pending set (each draft's plan index + reply
413
+ # text), not the constant batch_id. This means: re-present whenever NEW
414
+ # drafts arrive (the signature changes), but don't re-pop the identical
415
+ # cards we already showed for this same pending set. No restart is ever
416
+ # needed for new pending drafts to surface.
417
+ sig = tuple((d.get("n"), d.get("reply_text") or "") for d in drafts)
418
+ if sig == self._last_review_sig:
419
+ return
404
420
  self._review_active = True
405
- self._reviewed_batches.add(batch)
406
421
  try:
407
422
  import s4l_card
408
423
 
409
424
  s4l_card.present_review(
410
425
  drafts, lambda decisions: self._on_review_done(batch, decisions)
411
426
  )
427
+ # Record as shown only AFTER the cards are actually up, so a transient
428
+ # card-UI failure never permanently suppresses this pending set.
429
+ self._last_review_sig = sig
412
430
  except Exception as e:
413
431
  # Card UI unavailable — don't strand the batch; chat review still works.
414
432
  self._review_active = False
@@ -572,9 +590,10 @@ class S4LMenuBar(rumps.App):
572
590
  out.append(
573
591
  rumps.MenuItem("Run draft cycle in Claude", callback=self._draft)
574
592
  )
575
- out.append(
576
- rumps.MenuItem("Post approved drafts in Claude", callback=self._post)
577
- )
593
+ # No "Post approved drafts" item: approving a review card already posts
594
+ # directly + programmatically (_on_review_done -> st.post_drafts -> the
595
+ # CDP poster). A menu button that types a prompt into the chat to do the
596
+ # same thing was a redundant detour through the model, so it's gone.
578
597
  return out
579
598
 
580
599
 
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.80",
3
+ "version": "1.6.82",
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.80",
3
+ "version": "1.6.82",
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"
@@ -45,6 +45,7 @@ import random
45
45
  import re
46
46
  import subprocess
47
47
  import sys
48
+ import time
48
49
  from datetime import datetime, timezone
49
50
  from pathlib import Path
50
51
 
@@ -845,6 +846,39 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
845
846
  return ("posted", "")
846
847
 
847
848
 
849
+ def _saps_state_dir() -> str:
850
+ return os.environ.get("SAPS_STATE_DIR") or os.path.join(
851
+ os.path.expanduser("~"), ".social-autoposter-mcp")
852
+
853
+
854
+ def _write_activity(label: str) -> None:
855
+ """Best-effort live status for the S4L menu bar, which polls
856
+ <state_dir>/activity.json. Mirrors the Node server's writeActivity shape so
857
+ the menu-bar spinner renders our per-post progress ("posting 3/10", then
858
+ "posted 3/10 ✓"). Purely cosmetic: a failure here never affects posting."""
859
+ try:
860
+ sd = _saps_state_dir()
861
+ os.makedirs(sd, exist_ok=True)
862
+ payload = {"state": "working", "label": label,
863
+ "since": datetime.now(timezone.utc).isoformat()}
864
+ with open(os.path.join(sd, "activity.json"), "w", encoding="utf-8") as f:
865
+ f.write(json.dumps(payload) + "\n")
866
+ except Exception:
867
+ pass
868
+
869
+
870
+ def _clear_activity() -> None:
871
+ """Remove our status so neither an early exit nor the cron path (which does
872
+ NOT go through the MCP runTool's clear) leaves a stale 'posting/posted' stuck
873
+ in the menu bar. Double-clearing with runTool is harmless."""
874
+ try:
875
+ p = os.path.join(_saps_state_dir(), "activity.json")
876
+ if os.path.exists(p):
877
+ os.remove(p)
878
+ except Exception:
879
+ pass
880
+
881
+
848
882
  def main() -> int:
849
883
  ap = argparse.ArgumentParser()
850
884
  ap.add_argument("--plan", required=True,
@@ -924,26 +958,37 @@ def main() -> int:
924
958
  f"approved in plan (pass --post-unapproved to override)", flush=True)
925
959
  candidates = _kept
926
960
 
927
- for c in candidates:
928
- try:
929
- outcome, reason = post_one(c, picker_assignment=picker_assignment)
930
- except Exception as e:
931
- print(f"[post] candidate {c.get('candidate_id')} crashed: {e}",
932
- flush=True)
933
- outcome, reason = ("failed", "exception")
934
- cid = c.get("candidate_id")
935
- if isinstance(cid, int):
936
- update_candidate(cid, "skipped", "exception")
937
- if outcome == "posted":
938
- posted += 1
939
- elif outcome == "skipped":
940
- skipped += 1
941
- if reason:
942
- skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
943
- else:
944
- failed += 1
945
- if reason:
946
- fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
961
+ _total = len(candidates)
962
+ try:
963
+ for _idx, c in enumerate(candidates, start=1):
964
+ # Live per-post status for the S4L menu bar: "posting 3/10" while this
965
+ # one is in flight, then "posted 3/10 ✓" once it lands. Cosmetic only.
966
+ _write_activity(f"posting {_idx}/{_total}")
967
+ try:
968
+ outcome, reason = post_one(c, picker_assignment=picker_assignment)
969
+ except Exception as e:
970
+ print(f"[post] candidate {c.get('candidate_id')} crashed: {e}",
971
+ flush=True)
972
+ outcome, reason = ("failed", "exception")
973
+ cid = c.get("candidate_id")
974
+ if isinstance(cid, int):
975
+ update_candidate(cid, "skipped", "exception")
976
+ if outcome == "posted":
977
+ posted += 1
978
+ # Flash the confirmation with a short dwell so the menu bar shows
979
+ # it before the next iteration's "posting" overwrites the label.
980
+ _write_activity(f"posted {_idx}/{_total} ✓")
981
+ time.sleep(0.6)
982
+ elif outcome == "skipped":
983
+ skipped += 1
984
+ if reason:
985
+ skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
986
+ else:
987
+ failed += 1
988
+ if reason:
989
+ fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
990
+ finally:
991
+ _clear_activity()
947
992
 
948
993
  summary = {
949
994
  "posted": posted,