social-autoposter 1.6.93 → 1.6.95

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
@@ -18,7 +18,7 @@ import os from "node:os";
18
18
  import path from "node:path";
19
19
  import fs from "node:fs";
20
20
  import { repoDir, runPython, run, readPlan, writePlan, planPath, scanResultPath, } from "./repo.js";
21
- import { applySetup, resolveProject, listManagedProjectStatus, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
21
+ import { applySetup, resolveProject, listManagedProjectStatus, ensureShortLinksDefault, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
22
22
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
23
23
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
24
24
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
@@ -655,6 +655,7 @@ async function postApproved(batchId, plan) {
655
655
  // the post for the browser. Reset is guaranteed by scheduleShellLockRelease()
656
656
  // in the finally below, so an early/failed post can't wedge scanning.
657
657
  postingActive = true;
658
+ startPostingFlagHeartbeat(); // cross-instance: a sibling MCP's scan defers too
658
659
  // Posting is a priority over scanning: abort any in-flight plugin scan so the
659
660
  // approved post takes the browser immediately instead of waiting on the lock.
660
661
  // Plugin pipeline only — never affects the plist autopilot.
@@ -2020,6 +2021,64 @@ function sapsStateDir() {
2020
2021
  return (process.env.SAPS_STATE_DIR ||
2021
2022
  path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp"));
2022
2023
  }
2024
+ // ---- Cross-instance "posting active" flag ----------------------------------
2025
+ // posting-active.json in the shared state dir is the CROSS-MCP-INSTANCE version
2026
+ // of the in-process `postingActive` flag. The autopilot scan and the post
2027
+ // sometimes run in the SAME MCP (the in-process flag covers that) and sometimes
2028
+ // in TWO SEPARATE MCP instances (different agent sessions each spawn their own).
2029
+ // A file every instance's scan_candidates reads makes the mutual exclusion hold
2030
+ // regardless of which topology Claude Desktop happens to use. Heartbeat'd with a
2031
+ // short TTL so a crashed poster's flag self-clears and never wedges scanning.
2032
+ const POSTING_FLAG_TTL_MS = 45_000;
2033
+ let postingFlagHeartbeat = null;
2034
+ function postingFlagPath() {
2035
+ return path.join(sapsStateDir(), "posting-active.json");
2036
+ }
2037
+ function writePostingFlag() {
2038
+ try {
2039
+ fs.mkdirSync(sapsStateDir(), { recursive: true });
2040
+ fs.writeFileSync(postingFlagPath(), JSON.stringify({ pid: process.pid, expires_at: Date.now() + POSTING_FLAG_TTL_MS }) + "\n", "utf-8");
2041
+ }
2042
+ catch {
2043
+ /* best effort */
2044
+ }
2045
+ }
2046
+ function startPostingFlagHeartbeat() {
2047
+ writePostingFlag();
2048
+ if (postingFlagHeartbeat)
2049
+ return;
2050
+ // Refresh well within the TTL so a long batch stays flagged, but a dead poster
2051
+ // expires within POSTING_FLAG_TTL_MS.
2052
+ postingFlagHeartbeat = setInterval(() => {
2053
+ if (postingActive)
2054
+ writePostingFlag();
2055
+ }, Math.floor(POSTING_FLAG_TTL_MS / 2));
2056
+ if (typeof postingFlagHeartbeat.unref === "function")
2057
+ postingFlagHeartbeat.unref();
2058
+ }
2059
+ function stopPostingFlagHeartbeat() {
2060
+ if (postingFlagHeartbeat) {
2061
+ clearInterval(postingFlagHeartbeat);
2062
+ postingFlagHeartbeat = null;
2063
+ }
2064
+ try {
2065
+ fs.rmSync(postingFlagPath(), { force: true });
2066
+ }
2067
+ catch {
2068
+ /* best effort */
2069
+ }
2070
+ }
2071
+ // True when ANY MCP instance has a FRESH posting flag on disk. Absent or expired
2072
+ // == not posting. This is what makes a sibling instance's scan_candidates defer.
2073
+ function isPostingFlagFresh() {
2074
+ try {
2075
+ const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
2076
+ return typeof j?.expires_at === "number" && j.expires_at > Date.now();
2077
+ }
2078
+ catch {
2079
+ return false;
2080
+ }
2081
+ }
2023
2082
  // activity.json: a tiny "what's running right now" signal the menu bar reads to
2024
2083
  // show a loading spinner + label (scanning / drafting / posting / …). Written by
2025
2084
  // long-running tools, cleared when they finish. Best-effort; absence == idle.
@@ -2327,6 +2386,7 @@ function scheduleShellLockRelease() {
2327
2386
  shellLockReleaseTimer = setTimeout(() => {
2328
2387
  shellLockReleaseTimer = null;
2329
2388
  postingActive = false; // posting drained -> the autopilot may scan again
2389
+ stopPostingFlagHeartbeat(); // clear the cross-instance flag too
2330
2390
  releaseShellBrowserLock();
2331
2391
  }, SHELL_LOCK_GRACE_MS);
2332
2392
  }
@@ -2573,7 +2633,7 @@ tool("scan_candidates", {
2573
2633
  // post are children of THIS MCP, so this in-process gate is exact — the
2574
2634
  // autopilot never even starts a scan to interrupt a post. Posting always wins;
2575
2635
  // the autopilot just re-calls and runs once the batch drains.
2576
- if (postingActive)
2636
+ if (postingActive || isPostingFlagFresh())
2577
2637
  return scanDeferredForPost();
2578
2638
  // Long-poll the single in-flight scan job (see the ScanJob registry above).
2579
2639
  // A finished-but-unconsumed job: hand back its result and clear the slot so a
@@ -2927,6 +2987,19 @@ async function main() {
2927
2987
  // scan -> submit path never stalls on an "Ask mode" permission prompt (see the
2928
2988
  // helper's note). Best-effort, allow-only, gated on the task existing.
2929
2989
  ensureAutopilotToolsAllowed();
2990
+ // Heal installs onboarded before short_links_live defaulted to false: such a
2991
+ // project wraps short links against the customer's own domain, which has no
2992
+ // /r/[code] resolver, so every minted link 404s. Re-point them at the s4l.ai
2993
+ // resolver. Idempotent, scoped to managed projects, best-effort.
2994
+ try {
2995
+ const r = ensureShortLinksDefault();
2996
+ if (r.healed.length) {
2997
+ console.error(`[social-autoposter-mcp] short-links heal: routed ${r.healed.join(", ")} through s4l.ai (short_links_live=false)`);
2998
+ }
2999
+ }
3000
+ catch (e) {
3001
+ console.error("[social-autoposter-mcp] short-links heal failed:", e?.message || e);
3002
+ }
2930
3003
  const transport = new StdioServerTransport();
2931
3004
  await server.connect(transport);
2932
3005
  console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
package/mcp/dist/setup.js CHANGED
@@ -158,6 +158,16 @@ export function buildProjectEntry(input) {
158
158
  weight: 10,
159
159
  platform: "twitter",
160
160
  voice_relationship: "first_party",
161
+ // A freshly onboarded customer has NOT shipped the @m13v/seo-components
162
+ // /r/[code] resolver on their own domain, so a wrapped short link minted
163
+ // against project.website would 404 ("this link doesn't exist"). Default
164
+ // new projects to route /r/<code> through the social-autoposter-owned
165
+ // resolver at https://s4l.ai instead: short_links_live=false makes
166
+ // _project_short_links_host / getProjectWrapperHost fall back to
167
+ // DEFAULT_FALLBACK_HOST. The customer flips this to true (or removes it)
168
+ // only AFTER they ship their own /r/[code] handler. See the "URL wrapping"
169
+ // section in CLAUDE.md.
170
+ short_links_live: false,
161
171
  ...userFields(input),
162
172
  };
163
173
  // Generic fields can override the defaults above (e.g. platform/weight) and
@@ -211,6 +221,52 @@ export function applySetup(input) {
211
221
  fields_removed,
212
222
  };
213
223
  }
224
+ // Heal installs that onboarded BEFORE short_links_live defaulted to false.
225
+ // Such a project has neither short_links_host nor an explicit short_links_live,
226
+ // so the wrapper host resolves to project.website — but the customer never
227
+ // shipped a /r/[code] resolver there, so every minted short link 404s. Set
228
+ // short_links_live=false (route /r/<code> through s4l.ai) for those projects.
229
+ //
230
+ // Scoped to projects this install actually manages, so a hand-maintained dev
231
+ // config with branded-resolver projects isn't rewritten. Idempotent: a project
232
+ // that already has either short-link field set is left untouched (someone made
233
+ // a deliberate choice). Best-effort; never throws.
234
+ export function ensureShortLinksDefault() {
235
+ const healed = [];
236
+ try {
237
+ const cfg = readConfig();
238
+ const projects = cfg.projects || [];
239
+ if (!projects.length)
240
+ return { healed };
241
+ const managed = new Set(managedProjects());
242
+ let changed = false;
243
+ for (const p of projects) {
244
+ const name = typeof p.name === "string" ? p.name : "";
245
+ if (!name || !managed.has(name))
246
+ continue;
247
+ const hasHost = typeof p.short_links_host === "string" && p.short_links_host.trim() !== "";
248
+ const hasLiveFlag = p.short_links_live === true || p.short_links_live === false;
249
+ if (hasHost || hasLiveFlag)
250
+ continue; // deliberate config: leave it.
251
+ p.short_links_live = false;
252
+ healed.push(name);
253
+ changed = true;
254
+ }
255
+ if (changed) {
256
+ const cfgPath = configPath();
257
+ if (fs.existsSync(cfgPath)) {
258
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
259
+ fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
260
+ }
261
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
262
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
263
+ }
264
+ }
265
+ catch {
266
+ // best-effort heal; a failure here must never block boot.
267
+ }
268
+ return { healed };
269
+ }
214
270
  // ---------------------------------------------------------------------------
215
271
  // Readiness (derived from config.json, never stored).
216
272
  // ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.93",
3
- "installedAt": "2026-06-23T20:32:39.303Z"
2
+ "version": "1.6.95",
3
+ "installedAt": "2026-06-24T00:44:52.989Z"
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.93",
5
+ "version": "1.6.95",
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": {
@@ -17,6 +17,7 @@ run loop, so that holds).
17
17
  import objc
18
18
  from Foundation import NSObject, NSMakeRect
19
19
  from AppKit import (
20
+ NSApp,
20
21
  NSPanel,
21
22
  NSButton,
22
23
  NSTextField,
@@ -108,8 +109,23 @@ class _ReviewController(NSObject):
108
109
  panel.setDelegate_(self)
109
110
  self._panel = panel
110
111
  self._render()
112
+ # The menu bar app runs as an accessory (LSUIElement, no dock icon), so
113
+ # ordering the panel front is NOT enough to type into the reply field:
114
+ # keystrokes only route to a text view when the OWNING APP is the active
115
+ # application. Without this activation the cursor appears in the field but
116
+ # every keypress goes to whatever app is actually frontmost, which is the
117
+ # "editable field that isn't editable at all" bug. Activate the app, make
118
+ # the panel key, then drop the caret into the reply text view.
119
+ try:
120
+ NSApp.activateIgnoringOtherApps_(True)
121
+ except Exception:
122
+ pass
111
123
  panel.makeKeyAndOrderFront_(None)
112
124
  panel.orderFrontRegardless()
125
+ # _render() already seated the caret in the reply field; re-seat once more
126
+ # after the panel is key so the very first card is editable immediately.
127
+ if self._textview is not None:
128
+ panel.makeFirstResponder_(self._textview)
113
129
 
114
130
  @objc.python_method
115
131
  def _render(self):
@@ -182,6 +198,10 @@ class _ReviewController(NSObject):
182
198
  self._panel.setContentView_(content)
183
199
  # Counter lives in the native title bar, not inside the content.
184
200
  self._panel.setTitle_(f"Review draft {self._idx + 1} of {len(self._drafts)}")
201
+ # setContentView_ rebuilds the view tree, so the caret would otherwise
202
+ # default to the Approve button. Re-seat it in the reply field for every
203
+ # card (not just the first) so each one is immediately editable.
204
+ self._panel.makeFirstResponder_(tv)
185
205
 
186
206
  @objc.python_method
187
207
  def _current_text(self):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.93",
3
+ "version": "1.6.95",
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.93",
3
+ "version": "1.6.95",
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"