social-autoposter 1.6.98 → 1.6.99

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
@@ -524,7 +524,7 @@ function renderDraftsTable(plan) {
524
524
  // Number by FULL-array index (matches post_drafts + the menu bar), then drop
525
525
  // already-finished entries so the cards only show what's still pending.
526
526
  .map((c, i) => ({ c, n: i + 1 }))
527
- .filter((e) => e.c.posted !== true && e.c.terminal !== true)
527
+ .filter((e) => e.c.posted !== true && e.c.terminal !== true && e.c.approved !== true)
528
528
  // The queue is append-only; newest drafts have the highest stable index.
529
529
  // Show those first so review starts with likely-live tweets instead of stale
530
530
  // low-number drafts that have been sitting around for hours.
@@ -637,7 +637,13 @@ async function ensurePostingHandle() {
637
637
  }
638
638
  }
639
639
  async function postApproved(batchId, plan) {
640
- const approved = (plan.candidates || []).filter((c) => c.approved === true);
640
+ // Post every card the user APPROVED that hasn't already landed or been ruled out.
641
+ // `approved` is now a DURABLE decision (sticky, never cleared by a later call), so
642
+ // filtering out posted/terminal here makes this idempotent: re-running it only
643
+ // drains the not-yet-posted approved backlog (e.g. a card a restart interrupted),
644
+ // never re-posts a done one. This is what lets the startup backlog-drain and the
645
+ // per-card menu-bar calls share one code path safely.
646
+ const approved = (plan.candidates || []).filter((c) => c.approved === true && c.posted !== true && c.terminal !== true);
641
647
  if (approved.length === 0)
642
648
  return { attempted: 0, exit_code: 0, summary: "nothing approved" };
643
649
  // PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
@@ -1402,8 +1408,13 @@ tool("post_drafts", {
1402
1408
  .optional()
1403
1409
  .describe("Rewrites: each {n, text} replaces draft n's wording, then posts it."),
1404
1410
  post_all: z.boolean().optional().describe("Post every draft in the batch."),
1411
+ reject: z
1412
+ .array(z.number().int().positive())
1413
+ .optional()
1414
+ .describe("1-based draft numbers the user REJECTED. They are marked done and never " +
1415
+ "shown for review again, and are not posted."),
1405
1416
  },
1406
- }, async ({ batch_id, post, edits, post_all }) => {
1417
+ }, async ({ batch_id, post, edits, post_all, reject }) => {
1407
1418
  const plan = readPlan(batch_id);
1408
1419
  if (!plan || !(plan.candidates && plan.candidates.length)) {
1409
1420
  return textContent(`No drafts found for batch ${batch_id}. Run scan_candidates then submit_drafts again to produce a fresh batch.`);
@@ -1412,6 +1423,25 @@ tool("post_drafts", {
1412
1423
  const total = candidates.length;
1413
1424
  const warnings = [];
1414
1425
  const inRange = (n) => n >= 1 && n <= total;
1426
+ // ---- Rejections: durable + final --------------------------------------
1427
+ // A rejected draft is marked terminal so it NEVER re-appears for review and is
1428
+ // never posted. A reject overrides any earlier approve on the same card.
1429
+ const rejected = [];
1430
+ (reject || []).forEach((n) => {
1431
+ if (!inRange(n)) {
1432
+ warnings.push(`ignored reject #${n}: out of range (1-${total})`);
1433
+ return;
1434
+ }
1435
+ const c = candidates[n - 1];
1436
+ if (c.posted === true) {
1437
+ warnings.push(`#${n} already posted; not rejecting`);
1438
+ return;
1439
+ }
1440
+ c.terminal = true;
1441
+ c.terminal_reason = "rejected";
1442
+ c.approved = false;
1443
+ rejected.push(n);
1444
+ });
1415
1445
  // Apply edits first; an edited draft is always posted.
1416
1446
  const approve = new Set();
1417
1447
  let editedCount = 0;
@@ -1440,27 +1470,39 @@ tool("post_drafts", {
1440
1470
  warnings.push(`ignored #${n}: out of range (1-${total})`);
1441
1471
  });
1442
1472
  // Cross-surface de-dup: chat and the menu-bar pop-ups can both approve, so
1443
- // never re-post a candidate the other surface already posted.
1444
- const alreadyPosted = [];
1473
+ // never re-post a candidate the other surface already posted OR ruled out.
1474
+ const alreadyDone = [];
1445
1475
  for (const n of Array.from(approve)) {
1446
- if (candidates[n - 1]?.posted === true) {
1476
+ if (candidates[n - 1]?.posted === true || candidates[n - 1]?.terminal === true) {
1447
1477
  approve.delete(n);
1448
- alreadyPosted.push(n);
1478
+ alreadyDone.push(n);
1449
1479
  }
1450
1480
  }
1451
- if (alreadyPosted.length) {
1452
- warnings.push(`already posted (skipped): ${alreadyPosted.sort((a, b) => a - b).join(", ")}`);
1453
- }
1454
- candidates.forEach((c, i) => (c.approved = approve.has(i + 1)));
1481
+ if (alreadyDone.length) {
1482
+ warnings.push(`already posted/decided (skipped): ${alreadyDone.sort((a, b) => a - b).join(", ")}`);
1483
+ }
1484
+ // STICKY approve: record the approval DURABLY and never clear another card's
1485
+ // prior approval. The old `c.approved = approve.has(i+1)` reset every card on
1486
+ // each call, so a later post_drafts for a different card dropped a
1487
+ // restart-interrupted approved card back into "pending". postApproved filters
1488
+ // posted/terminal, so the approved set only ever drains what's genuinely left.
1489
+ approve.forEach((n) => {
1490
+ const c = candidates[n - 1];
1491
+ if (c)
1492
+ c.approved = true;
1493
+ });
1455
1494
  writePlan(batch_id, plan);
1456
1495
  if (approve.size === 0) {
1457
1496
  return jsonContent({
1458
1497
  batch_id,
1459
1498
  drafted: total,
1460
1499
  posted: 0,
1500
+ rejected: rejected.length,
1461
1501
  skipped: total,
1462
1502
  edited: editedCount,
1463
- note: "No drafts selected to post. Nothing was posted.",
1503
+ note: rejected.length
1504
+ ? `Rejected ${rejected.length} draft(s); they won't be shown for review again. Nothing was posted.`
1505
+ : "No drafts selected to post. Nothing was posted.",
1464
1506
  warnings,
1465
1507
  });
1466
1508
  }
@@ -1478,6 +1520,7 @@ tool("post_drafts", {
1478
1520
  drafted: total,
1479
1521
  posted: actuallyPosted,
1480
1522
  approved: approve.size,
1523
+ rejected: rejected.length,
1481
1524
  skipped: total - actuallyPosted,
1482
1525
  edited: editedCount,
1483
1526
  result,
@@ -3056,7 +3099,10 @@ tool("submit_drafts", {
3056
3099
  added++;
3057
3100
  }
3058
3101
  writePlan(REVIEW_QUEUE_ID, { candidates: queue });
3059
- const pending = queue.filter((c) => c.posted !== true && c.terminal !== true);
3102
+ // Pending = NOT YET DECIDED. A card that's posted, terminal (rejected/dead), OR
3103
+ // already approved is a settled decision and must never be re-presented for
3104
+ // review — approved ones just proceed to post (see drainApprovedBacklog).
3105
+ const pending = queue.filter((c) => c.posted !== true && c.terminal !== true && c.approved !== true);
3060
3106
  // Drafts queued = the pipeline verified end-to-end without posting. This is the
3061
3107
  // onboarding "draft_verified" terminal goal (formerly completed by draft_cycle).
3062
3108
  if (added > 0)
@@ -3266,6 +3312,24 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
3266
3312
  },
3267
3313
  ],
3268
3314
  }));
3315
+ // Post any cards the user APPROVED that never landed — e.g. a restart killed the
3316
+ // batch mid-way. "Proceed to post the already-approved items." postApproved is
3317
+ // idempotent (it filters posted/terminal), so this only drains the genuine
3318
+ // backlog and never double-posts. Best-effort; never throws.
3319
+ async function drainApprovedBacklog() {
3320
+ try {
3321
+ const plan = readPlan(REVIEW_QUEUE_ID);
3322
+ const cands = plan?.candidates || [];
3323
+ const backlog = cands.filter((c) => c.approved === true && c.posted !== true && c.terminal !== true);
3324
+ if (!backlog.length)
3325
+ return;
3326
+ console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
3327
+ await postApproved(REVIEW_QUEUE_ID, plan);
3328
+ }
3329
+ catch (e) {
3330
+ console.error("[post] drainApprovedBacklog error:", e?.message || e);
3331
+ }
3332
+ }
3269
3333
  async function main() {
3270
3334
  initSentry();
3271
3335
  // Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
@@ -3330,6 +3394,13 @@ async function main() {
3330
3394
  void startLocalPanel()
3331
3395
  .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
3332
3396
  .catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
3397
+ // Resume posting any approved-but-unposted cards a prior run/restart left behind.
3398
+ // Delayed so the runtime + harness Chrome have settled; never blocks boot.
3399
+ {
3400
+ const t = setTimeout(() => void drainApprovedBacklog(), 30_000);
3401
+ if (typeof t.unref === "function")
3402
+ t.unref();
3403
+ }
3333
3404
  // Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
3334
3405
  // and cheap when already present, so existing installs pick it up on the next
3335
3406
  // Claude restart without re-provisioning. Best-effort: never blocks boot.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.98",
3
- "installedAt": "2026-06-24T02:52:38.060Z"
2
+ "version": "1.6.99",
3
+ "installedAt": "2026-06-24T03:00:44.774Z"
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.98",
5
+ "version": "1.6.99",
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": {
@@ -544,10 +544,20 @@ class S4LMenuBar(rumps.App):
544
544
 
545
545
  def _on_card_decision(self, batch, decision):
546
546
  # Runs on the main thread the INSTANT a card is approved/rejected. An
547
- # approved card is enqueued for immediate posting; a rejected card does
548
- # nothing. We never post inline here posting can take minutes and would
549
- # freeze the card UI while the user reviews the rest of the stack.
547
+ # approved card is enqueued for immediate posting; a REJECTED card is
548
+ # persisted (marked done so it's never re-shown for review) on a quick
549
+ # background thread. We never block inline here posting can take minutes
550
+ # and would freeze the card UI while the user reviews the rest of the stack.
550
551
  if not decision.get("approved"):
552
+ n = decision.get("n")
553
+
554
+ def _persist_reject():
555
+ try:
556
+ st.post_drafts(batch, reject=[n], timeout=30)
557
+ except Exception:
558
+ pass
559
+
560
+ threading.Thread(target=_persist_reject, daemon=True).start()
551
561
  return
552
562
  with self._review_lock:
553
563
  self._posts_outstanding += 1
@@ -350,10 +350,12 @@ def read_plan(plan_path):
350
350
 
351
351
 
352
352
  def review_drafts(plan):
353
- """Flatten a plan into the card model: only unfinished candidates."""
353
+ """Flatten a plan into the card model: only UNDECIDED candidates. A card that's
354
+ posted, terminal (rejected/dead), or already approved is a settled decision and
355
+ must never be re-presented for review (approved ones proceed to post)."""
354
356
  out = []
355
357
  for i, c in enumerate(((plan or {}).get("candidates") or [])):
356
- if c.get("posted") is True or c.get("terminal") is True:
358
+ if c.get("posted") is True or c.get("terminal") is True or c.get("approved") is True:
357
359
  continue
358
360
  out.append(
359
361
  {
@@ -370,11 +372,12 @@ def review_drafts(plan):
370
372
  return out
371
373
 
372
374
 
373
- def post_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
374
- """Post approved drafts via the loopback tool. `post` = 1-based numbers to
375
- post as-is; `edits` = [{n, text}] to rewrite then post. Returns the parsed
375
+ def post_drafts(batch_id, post=None, edits=None, reject=None, timeout=900, activity_label=None):
376
+ """Post / reject drafts via the loopback tool. `post` = 1-based numbers to post
377
+ as-is; `edits` = [{n, text}] to rewrite then post; `reject` = numbers to mark
378
+ DONE so they're never shown for review again (not posted). Returns the parsed
376
379
  result, or None if the loopback is unreachable (Claude Desktop closed)."""
377
- args = {"batch_id": batch_id, "post": post or [], "edits": edits or []}
380
+ args = {"batch_id": batch_id, "post": post or [], "edits": edits or [], "reject": reject or []}
378
381
  if activity_label:
379
382
  args["__saps_activity_label"] = activity_label
380
383
  return loopback_tool("post_drafts", args, timeout=timeout)
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.98",
3
+ "version": "1.6.99",
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.98",
3
+ "version": "1.6.99",
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"