social-autoposter 1.6.81 → 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.81",
3
- "installedAt": "2026-06-22T19:29:05.014Z"
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.81",
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
 
@@ -183,9 +182,6 @@ class S4LMenuBar(rumps.App):
183
182
  def _draft(self, _=None):
184
183
  self._send_to_claude(DRAFT_PROMPT)
185
184
 
186
- def _post(self, _=None):
187
- self._send_to_claude(POST_PROMPT)
188
-
189
185
  def _update(self, _=None):
190
186
  self._send_to_claude(UPDATE_PROMPT)
191
187
 
@@ -319,8 +315,13 @@ class S4LMenuBar(rumps.App):
319
315
  act = st.read_activity()
320
316
  label = act.get("label") if act else None
321
317
  if label:
322
- self._spin_i = (self._spin_i + 1) % len(SPINNER)
323
- 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]}"
324
325
  return
325
326
  try:
326
327
  if self._spinner is not None:
@@ -589,9 +590,10 @@ class S4LMenuBar(rumps.App):
589
590
  out.append(
590
591
  rumps.MenuItem("Run draft cycle in Claude", callback=self._draft)
591
592
  )
592
- out.append(
593
- rumps.MenuItem("Post approved drafts in Claude", callback=self._post)
594
- )
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.
595
597
  return out
596
598
 
597
599
 
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.81",
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.81",
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,