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 +75 -2
- package/mcp/dist/setup.js +56 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +20 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
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
|
// ---------------------------------------------------------------------------
|
package/mcp/dist/version.json
CHANGED
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.
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -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.
|
|
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