@m13v/s4l 1.7.4-rc.32 → 1.7.4-rc.34
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/runtime.js +30 -0
- package/mcp/dist/version.js +163 -54
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +4 -3
- package/mcp/menubar/s4l_state.py +1 -1
- package/mcp/package.json +1 -1
- package/package.json +2 -1
- package/scripts/s4l_box_update.sh +20 -1
- package/scripts/s4l_mode.py +3 -4
- package/scripts/snapshot.py +178 -60
- package/skill/lib/linkedin-backend.sh +31 -17
- package/skill/lib/twitter-backend.sh +39 -19
- package/skill/run-twitter-cycle.sh +1 -1
- package/scripts/_lock_preempt_test.py +0 -60
- package/scripts/saps_activity.py +0 -18
- package/scripts/saps_mode.py +0 -18
package/mcp/dist/runtime.js
CHANGED
|
@@ -239,6 +239,30 @@ function bundledVersion() {
|
|
|
239
239
|
return null;
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
|
+
// `ensurePipelineCurrent()` extracts updates over the existing materialized
|
|
243
|
+
// repo so customer config and logs survive. That also means deleting a file
|
|
244
|
+
// from the npm tarball does not delete an older copy already on disk. Keep
|
|
245
|
+
// explicit tombstones for retired helpers long enough for the fleet to pass
|
|
246
|
+
// through this release; new installs never receive these files.
|
|
247
|
+
const RETIRED_MATERIALIZED_FILES = [
|
|
248
|
+
"scripts/saps_mode.py",
|
|
249
|
+
"scripts/saps_activity.py",
|
|
250
|
+
"scripts/_lock_preempt_test.py",
|
|
251
|
+
];
|
|
252
|
+
function removeRetiredMaterializedFiles() {
|
|
253
|
+
for (const rel of RETIRED_MATERIALIZED_FILES) {
|
|
254
|
+
const target = path.join(MATERIALIZED_REPO, rel);
|
|
255
|
+
try {
|
|
256
|
+
if (!fs.existsSync(target))
|
|
257
|
+
continue;
|
|
258
|
+
fs.rmSync(target, { force: true });
|
|
259
|
+
console.error(`[runtime] removed retired pipeline file ${rel}`);
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
console.error(`[runtime] could not remove retired pipeline file ${rel}: ${e?.message || e}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
242
266
|
// Re-materialize the pipeline source when a plugin UPDATE shipped a newer
|
|
243
267
|
// pipeline.tgz than what's on disk. The .mcpb update refreshes dist/ (this
|
|
244
268
|
// server) but does NOT re-extract the embedded tarball, so without this the box
|
|
@@ -269,6 +293,9 @@ export function ensurePipelineCurrent() {
|
|
|
269
293
|
const bundled = bundledVersion();
|
|
270
294
|
if (!bundled)
|
|
271
295
|
return; // dev build, no stamp — leave the materialized repo alone.
|
|
296
|
+
// Run before the version early-return too: a prior package build may have
|
|
297
|
+
// accidentally included a retired file under this same version.
|
|
298
|
+
removeRetiredMaterializedFiles();
|
|
272
299
|
const rt = readRuntime();
|
|
273
300
|
if (rt?.pipeline_version === bundled)
|
|
274
301
|
return; // already current.
|
|
@@ -283,6 +310,9 @@ export function ensurePipelineCurrent() {
|
|
|
283
310
|
console.error(`[runtime] pipeline re-materialize failed (exit ${r.status}); keeping existing pipeline`);
|
|
284
311
|
return;
|
|
285
312
|
}
|
|
313
|
+
// Defense in depth: a stale/locally-built tarball cannot reintroduce a
|
|
314
|
+
// retired helper even if its package allowlist drifts.
|
|
315
|
+
removeRetiredMaterializedFiles();
|
|
286
316
|
// Record the new version so we don't re-extract on every boot.
|
|
287
317
|
const next = rt ?? readRuntime();
|
|
288
318
|
if (next) {
|
package/mcp/dist/version.js
CHANGED
|
@@ -94,6 +94,67 @@ export function releaseChannel() {
|
|
|
94
94
|
}
|
|
95
95
|
return "stable";
|
|
96
96
|
}
|
|
97
|
+
const SHARED_TTL_S = 600; // banner latency ceiling: a new release surfaces within 10 min
|
|
98
|
+
const PROBE_LOCK_STALE_MS = 30_000;
|
|
99
|
+
function sharedCachePath() {
|
|
100
|
+
return path.join(STATE_DIR, "latest-release.json");
|
|
101
|
+
}
|
|
102
|
+
function readSharedCache(channel) {
|
|
103
|
+
try {
|
|
104
|
+
const d = JSON.parse(fs.readFileSync(sharedCachePath(), "utf-8"));
|
|
105
|
+
if (!d || d.channel !== channel || typeof d.at !== "number")
|
|
106
|
+
return null;
|
|
107
|
+
return {
|
|
108
|
+
at: d.at,
|
|
109
|
+
channel,
|
|
110
|
+
version: typeof d.version === "string" ? d.version : null,
|
|
111
|
+
tag: typeof d.tag === "string" ? d.tag : null,
|
|
112
|
+
etag: typeof d.etag === "string" ? d.etag : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function writeSharedCache(c) {
|
|
120
|
+
try {
|
|
121
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
122
|
+
const tmp = `${sharedCachePath()}.tmp.${process.pid}`;
|
|
123
|
+
fs.writeFileSync(tmp, JSON.stringify(c));
|
|
124
|
+
fs.renameSync(tmp, sharedCachePath());
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* best effort; worst case another process re-probes */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Single-flight guard so N concurrent processes with an expired shared cache
|
|
131
|
+
// don't all probe at once. acquired=false ONLY when another probe holds a
|
|
132
|
+
// FRESH lock; any other failure probes anyway rather than going blind.
|
|
133
|
+
function tryProbeLock() {
|
|
134
|
+
const lock = path.join(STATE_DIR, "latest-release.lock");
|
|
135
|
+
const create = () => fs.closeSync(fs.openSync(lock, "wx"));
|
|
136
|
+
try {
|
|
137
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
138
|
+
create();
|
|
139
|
+
return { acquired: true, lock };
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (e.code === "EEXIST") {
|
|
143
|
+
try {
|
|
144
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > PROBE_LOCK_STALE_MS) {
|
|
145
|
+
fs.unlinkSync(lock); // stale: holder died mid-probe
|
|
146
|
+
create();
|
|
147
|
+
return { acquired: true, lock };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* raced with the holder */
|
|
152
|
+
}
|
|
153
|
+
return { acquired: false, lock: null };
|
|
154
|
+
}
|
|
155
|
+
return { acquired: true, lock: null };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
97
158
|
function parseSemverish(v) {
|
|
98
159
|
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
99
160
|
}
|
|
@@ -128,41 +189,45 @@ async function latestFromGithubRedirect() {
|
|
|
128
189
|
return null;
|
|
129
190
|
}
|
|
130
191
|
}
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
192
|
+
// Conditional-request state lives in the SHARED cache file (latest-release.json)
|
|
193
|
+
// so the ETag survives process boundaries: short-lived MCP respawns used to pay
|
|
194
|
+
// a full 200 per process; now every probe sends If-None-Match and gets a free
|
|
195
|
+
// 304 between releases.
|
|
196
|
+
async function curlConditional(url, etag) {
|
|
197
|
+
const args = ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json"];
|
|
198
|
+
if (etag)
|
|
199
|
+
args.push("-H", `If-None-Match: ${etag}`);
|
|
200
|
+
args.push("-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url);
|
|
201
|
+
const res = await run("curl", args, { timeoutMs: 12000, noTee: true });
|
|
202
|
+
let status = 0;
|
|
203
|
+
let newEtag = null;
|
|
204
|
+
const body = [];
|
|
205
|
+
for (const line of (res.stdout || "").split("\n")) {
|
|
206
|
+
if (line.startsWith("__CURL_STATUS__:"))
|
|
207
|
+
status = parseInt(line.slice(16).trim(), 10) || 0;
|
|
208
|
+
else if (line.startsWith("__CURL_ETAG__:"))
|
|
209
|
+
newEtag = line.slice(14).trim() || null;
|
|
210
|
+
else
|
|
211
|
+
body.push(line);
|
|
212
|
+
}
|
|
213
|
+
return { status, etag: newEtag, body: body.join("\n") };
|
|
214
|
+
}
|
|
215
|
+
// Stable probe (releases/latest). On 304 serves the caller-supplied cached
|
|
216
|
+
// version with the same etag; If-None-Match is only sent when there IS a
|
|
217
|
+
// cached value to serve.
|
|
218
|
+
async function latestFromGithubApi(etag = null, cached = null) {
|
|
135
219
|
try {
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
let etag = null;
|
|
143
|
-
const body = [];
|
|
144
|
-
for (const line of (res.stdout || "").split("\n")) {
|
|
145
|
-
if (line.startsWith("__CURL_STATUS__:"))
|
|
146
|
-
status = parseInt(line.slice(16).trim(), 10) || 0;
|
|
147
|
-
else if (line.startsWith("__CURL_ETAG__:"))
|
|
148
|
-
etag = line.slice(14).trim() || null;
|
|
149
|
-
else
|
|
150
|
-
body.push(line);
|
|
151
|
-
}
|
|
152
|
-
if (status === 304)
|
|
153
|
-
return apiState.latest;
|
|
154
|
-
if (status !== 200)
|
|
155
|
-
return null;
|
|
156
|
-
const tag = JSON.parse(body.join("\n")).tag_name;
|
|
220
|
+
const r = await curlConditional(RELEASES_LATEST_API, cached ? etag : null);
|
|
221
|
+
if (r.status === 304)
|
|
222
|
+
return { version: cached, etag };
|
|
223
|
+
if (r.status !== 200)
|
|
224
|
+
return { version: null, etag: null };
|
|
225
|
+
const tag = JSON.parse(r.body).tag_name;
|
|
157
226
|
const v = typeof tag === "string" ? parseSemverish(tag.replace(/^v/, "").trim()) : null;
|
|
158
|
-
|
|
159
|
-
apiState.etag = etag;
|
|
160
|
-
apiState.latest = v;
|
|
161
|
-
}
|
|
162
|
-
return v;
|
|
227
|
+
return v ? { version: v, etag: r.etag } : { version: null, etag: null };
|
|
163
228
|
}
|
|
164
229
|
catch {
|
|
165
|
-
return null;
|
|
230
|
+
return { version: null, etag: null };
|
|
166
231
|
}
|
|
167
232
|
}
|
|
168
233
|
async function latestFromNpm() {
|
|
@@ -177,18 +242,24 @@ async function latestFromNpm() {
|
|
|
177
242
|
}
|
|
178
243
|
// Staging channel: newest release OVERALL (prereleases included) from the
|
|
179
244
|
// releases LIST, since releases/latest excludes prereleases. Drafts are skipped;
|
|
180
|
-
// "newest" is by the rc-aware verKey.
|
|
181
|
-
|
|
245
|
+
// "newest" is by the rc-aware verKey. On 304 serves the caller-supplied cached
|
|
246
|
+
// {version, tag} with the same etag. Returns {version, tag, etag} or null.
|
|
247
|
+
async function latestFromGithubListStaging(etag = null, cached = null) {
|
|
182
248
|
try {
|
|
183
|
-
const
|
|
184
|
-
const
|
|
249
|
+
const haveCached = !!(cached && cached.version && cached.tag);
|
|
250
|
+
const r = await curlConditional(RELEASES_LIST_API, haveCached ? etag : null);
|
|
251
|
+
if (r.status === 304 && haveCached)
|
|
252
|
+
return { version: cached.version, tag: cached.tag, etag };
|
|
253
|
+
if (r.status !== 200)
|
|
254
|
+
return null;
|
|
255
|
+
const rels = JSON.parse(r.body || "[]");
|
|
185
256
|
if (!Array.isArray(rels))
|
|
186
257
|
return null;
|
|
187
258
|
let best = null;
|
|
188
|
-
for (const
|
|
189
|
-
if (!
|
|
259
|
+
for (const rel of rels) {
|
|
260
|
+
if (!rel || typeof rel !== "object" || rel.draft)
|
|
190
261
|
continue;
|
|
191
|
-
const tag =
|
|
262
|
+
const tag = rel.tag_name;
|
|
192
263
|
if (typeof tag !== "string")
|
|
193
264
|
continue;
|
|
194
265
|
const v = parseSemverish(tag.replace(/^v/, "").trim());
|
|
@@ -198,7 +269,7 @@ async function latestFromGithubListStaging() {
|
|
|
198
269
|
if (best == null || cmpKey(key, best.key) > 0)
|
|
199
270
|
best = { version: v, tag, key };
|
|
200
271
|
}
|
|
201
|
-
return best ? { version: best.version, tag: best.tag } : null;
|
|
272
|
+
return best ? { version: best.version, tag: best.tag, etag: r.etag } : null;
|
|
202
273
|
}
|
|
203
274
|
catch {
|
|
204
275
|
return null;
|
|
@@ -221,28 +292,66 @@ async function resolveLatest() {
|
|
|
221
292
|
const now = Date.now();
|
|
222
293
|
if (cache && cache.channel === channel && now - cache.at < TTL_MS)
|
|
223
294
|
return { version: cache.latest, tag: cache.tag, channel };
|
|
295
|
+
// Level 2: shared cross-process cache file (see SharedCache above).
|
|
296
|
+
const shared = readSharedCache(channel);
|
|
297
|
+
if (shared && now / 1000 - shared.at < SHARED_TTL_S) {
|
|
298
|
+
cache = { at: now, latest: shared.version, tag: shared.tag, channel };
|
|
299
|
+
return { version: shared.version, tag: shared.tag, channel };
|
|
300
|
+
}
|
|
301
|
+
const { acquired, lock } = tryProbeLock();
|
|
302
|
+
if (!acquired) {
|
|
303
|
+
// Another process is probing right now; serve the stale shared value (or
|
|
304
|
+
// null) instead of doubling the request. The short in-process TTL means we
|
|
305
|
+
// pick up its fresh result within a minute.
|
|
306
|
+
const v = shared?.version ?? null;
|
|
307
|
+
const t = shared?.tag ?? null;
|
|
308
|
+
cache = { at: now, latest: v, tag: t, channel };
|
|
309
|
+
return { version: v, tag: t, channel };
|
|
310
|
+
}
|
|
224
311
|
let version = null;
|
|
225
312
|
let tag = null;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
313
|
+
let etag = null;
|
|
314
|
+
try {
|
|
315
|
+
if (channel === "staging") {
|
|
316
|
+
const s = await latestFromGithubListStaging(shared?.etag ?? null, shared ? { version: shared.version, tag: shared.tag } : null);
|
|
317
|
+
if (s) {
|
|
318
|
+
version = s.version;
|
|
319
|
+
tag = s.tag;
|
|
320
|
+
etag = s.etag;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
// Degrade to the stable probes so a staging box tracks at least stable
|
|
324
|
+
// rather than going blind when the list endpoint fails. etag stays
|
|
325
|
+
// null: a releases/latest etag must never be replayed against the
|
|
326
|
+
// LIST endpoint on the next staging probe.
|
|
327
|
+
version =
|
|
328
|
+
(await latestFromGithubApi()).version ?? (await latestFromGithubRedirect());
|
|
329
|
+
tag = version ? `v${version}` : null;
|
|
330
|
+
}
|
|
231
331
|
}
|
|
232
332
|
else {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
333
|
+
const r = await latestFromGithubApi(shared?.etag ?? null, shared?.version ?? null);
|
|
334
|
+
version = r.version;
|
|
335
|
+
etag = r.etag;
|
|
336
|
+
if (version == null) {
|
|
337
|
+
version = await latestFromGithubRedirect();
|
|
338
|
+
etag = null;
|
|
339
|
+
}
|
|
340
|
+
if (version == null)
|
|
341
|
+
version = await latestFromNpm();
|
|
236
342
|
tag = version ? `v${version}` : null;
|
|
237
343
|
}
|
|
344
|
+
writeSharedCache({ at: now / 1000, channel, version, tag, etag });
|
|
238
345
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
346
|
+
finally {
|
|
347
|
+
if (lock) {
|
|
348
|
+
try {
|
|
349
|
+
fs.unlinkSync(lock);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
/* already gone */
|
|
353
|
+
}
|
|
354
|
+
}
|
|
246
355
|
}
|
|
247
356
|
cache = { at: now, latest: version, tag, channel };
|
|
248
357
|
return { version, tag, channel };
|
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.7.4-rc.
|
|
5
|
+
"version": "1.7.4-rc.34",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -83,9 +83,10 @@ sys.stderr = _TimestampedStderr(sys.stderr)
|
|
|
83
83
|
# best-effort: a missing repo path or SDK degrades to a silent no-op.
|
|
84
84
|
_sentry = None
|
|
85
85
|
try:
|
|
86
|
-
# Read the repo dir from the plist env
|
|
87
|
-
#
|
|
88
|
-
|
|
86
|
+
# Read the repo dir from the plist env. This runs BEFORE scripts/ is on
|
|
87
|
+
# sys.path, so the s4l_env mirror cannot help yet; the old key is accepted
|
|
88
|
+
# only at this bootstrap seam so pre-rename plists can find the mirror.
|
|
89
|
+
_repo = os.environ.get("S4L_REPO_DIR") or os.environ.get("SAPS_REPO_DIR")
|
|
89
90
|
if _repo:
|
|
90
91
|
_scripts = os.path.join(_repo, "scripts")
|
|
91
92
|
if _scripts not in sys.path:
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -31,7 +31,7 @@ from pathlib import Path
|
|
|
31
31
|
# The repo dir must be read tolerantly INLINE (old name included) because the
|
|
32
32
|
# mirror module itself lives in $REPO/scripts and isn't importable before the
|
|
33
33
|
# sys.path insertion below. Best-effort: failure degrades to defaults.
|
|
34
|
-
_repo_for_env = os.environ.get("S4L_REPO_DIR")
|
|
34
|
+
_repo_for_env = os.environ.get("S4L_REPO_DIR") or os.environ.get("SAPS_REPO_DIR")
|
|
35
35
|
if _repo_for_env:
|
|
36
36
|
_scripts_for_env = os.path.join(_repo_for_env, "scripts")
|
|
37
37
|
if _scripts_for_env not in sys.path:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.34",
|
|
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": "@m13v/s4l",
|
|
3
|
-
"version": "1.7.4-rc.
|
|
3
|
+
"version": "1.7.4-rc.34",
|
|
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",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"!scripts/_serp_report.py",
|
|
58
58
|
"!scripts/_serp_vs_gsc_report.py",
|
|
59
59
|
"!scripts/_test_since_hook.py",
|
|
60
|
+
"!scripts/_lock_preempt_test.py",
|
|
60
61
|
"!scripts/add_deploy_metadata.py",
|
|
61
62
|
"!scripts/add_nightowl_to_config.py",
|
|
62
63
|
"!scripts/amplitude_user_lookup.py",
|
|
@@ -61,7 +61,7 @@ esac
|
|
|
61
61
|
# four space-separated tokens: "<channel> <tag> <version> <mcpb_url>".
|
|
62
62
|
STATE_DIR="${S4L_STATE_DIR:-$HOME/.social-autoposter-mcp}"
|
|
63
63
|
RESOLVED="$(S4L_STATE_DIR="$STATE_DIR" "$PY" - <<'PYEOF' 2>/dev/null || true
|
|
64
|
-
import json, os, re, subprocess
|
|
64
|
+
import json, os, re, subprocess, time
|
|
65
65
|
|
|
66
66
|
state = os.environ.get("S4L_STATE_DIR") or os.path.join(os.path.expanduser("~"), ".social-autoposter-mcp")
|
|
67
67
|
try:
|
|
@@ -113,6 +113,25 @@ if best is None:
|
|
|
113
113
|
print("unresolved")
|
|
114
114
|
raise SystemExit(0)
|
|
115
115
|
tag = best[1]
|
|
116
|
+
# Deposit the freshly resolved release into the SHARED cross-process cache
|
|
117
|
+
# (<state dir>/latest-release.json) that version.ts and snapshot.py read, so
|
|
118
|
+
# this probe also feeds the menu bar / MCP for the next TTL window instead of
|
|
119
|
+
# them re-spending quota. etag stays null (this resolver does not capture one);
|
|
120
|
+
# the next conditional probe simply pays one 200 and re-seeds it. Best effort:
|
|
121
|
+
# a failed write never blocks the update itself. NOTE: staging resolves here
|
|
122
|
+
# from the same LIST endpoint the other surfaces use, but stable picks newest
|
|
123
|
+
# non-prerelease from the LIST while they use releases/latest — same result,
|
|
124
|
+
# so the cache stays consistent either way.
|
|
125
|
+
try:
|
|
126
|
+
os.makedirs(state, exist_ok=True)
|
|
127
|
+
_p = os.path.join(state, "latest-release.json")
|
|
128
|
+
_tmp = "%s.tmp.%d" % (_p, os.getpid())
|
|
129
|
+
with open(_tmp, "w") as _f:
|
|
130
|
+
json.dump({"at": time.time(), "channel": channel,
|
|
131
|
+
"version": tag.lstrip("v"), "tag": tag, "etag": None}, _f)
|
|
132
|
+
os.replace(_tmp, _p)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
116
135
|
print("%s %s %s %s" % (channel, tag, tag.lstrip("v"), TAG_DL % (REPO, tag)))
|
|
117
136
|
PYEOF
|
|
118
137
|
)"
|
package/scripts/s4l_mode.py
CHANGED
|
@@ -70,9 +70,8 @@ toggle): this CLI and mode.json are the only way to flip it.
|
|
|
70
70
|
`env` additionally exports S4L_CYCLE_LANE=<lane> for both lanes so the wrapper
|
|
71
71
|
knows which lane this cycle is without re-deriving it.
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
run-twitter-cycle.sh. Do not reference the old name in new code.
|
|
73
|
+
The pre-2026-07-06 helper filename is retired. Current code must import or
|
|
74
|
+
invoke this module directly.
|
|
76
75
|
"""
|
|
77
76
|
|
|
78
77
|
import json
|
|
@@ -101,7 +100,7 @@ DEFAULT_PERSONAL_BRAND_SHARE = 0.5
|
|
|
101
100
|
|
|
102
101
|
|
|
103
102
|
def state_dir() -> Path:
|
|
104
|
-
# Mirrors mcp/src/index.ts
|
|
103
|
+
# Mirrors mcp/src/index.ts s4lStateDir() and menubar/s4l_state.py state_dir().
|
|
105
104
|
return Path(
|
|
106
105
|
os.environ.get("S4L_STATE_DIR")
|
|
107
106
|
or (Path.home() / ".social-autoposter-mcp")
|
package/scripts/snapshot.py
CHANGED
|
@@ -325,6 +325,81 @@ def _x_status():
|
|
|
325
325
|
_ver_cache = {"at": 0.0, "latest": None, "tag": None, "channel": None, "checked": False}
|
|
326
326
|
_VER_TTL = 55.0
|
|
327
327
|
|
|
328
|
+
# SHARED CROSS-PROCESS CACHE (2026-07-13): <state dir>/latest-release.json.
|
|
329
|
+
# Every surface that resolves the newest release (this module, mcp/src/
|
|
330
|
+
# version.ts, scripts/s4l_box_update.sh) reads and writes THIS one file, so a
|
|
331
|
+
# box makes at most one real GitHub probe per _SHARED_TTL no matter how many
|
|
332
|
+
# short-lived processes spin up (MCP servers respawn per s4l-worker session,
|
|
333
|
+
# buildSnapshot shells this module as a fresh subprocess, the menu bar ticks).
|
|
334
|
+
# The persisted ETag makes even those probes quota-free between releases (304s
|
|
335
|
+
# do not count against the anonymous 60/h-per-IP quota, and before this cache
|
|
336
|
+
# each process held its OWN in-process ETag that died with the process, so
|
|
337
|
+
# every respawn paid a full 200). Added after the box's aggregate probing
|
|
338
|
+
# (~80-100 req/h across processes) burned the quota on 2026-07-13 and silenced
|
|
339
|
+
# the update banner. Failures (version=None) are cached too, same rationale as
|
|
340
|
+
# _ver_cache. Keep the file shape in lockstep with version.ts::SharedCache:
|
|
341
|
+
# {"at": <epoch s>, "channel": ..., "version": ..., "tag": ..., "etag": ...}
|
|
342
|
+
_SHARED_TTL = 600.0 # banner latency ceiling: a new release surfaces within 10 min
|
|
343
|
+
_PROBE_LOCK_STALE = 30.0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _shared_cache_path():
|
|
347
|
+
return os.path.join(_state_dir(), "latest-release.json")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _read_shared_cache(channel):
|
|
351
|
+
d = _read_json(_shared_cache_path())
|
|
352
|
+
if not isinstance(d, dict) or d.get("channel") != channel:
|
|
353
|
+
return None
|
|
354
|
+
if not isinstance(d.get("at"), (int, float)):
|
|
355
|
+
return None
|
|
356
|
+
return {
|
|
357
|
+
"at": float(d["at"]),
|
|
358
|
+
"channel": channel,
|
|
359
|
+
"version": d.get("version") if isinstance(d.get("version"), str) else None,
|
|
360
|
+
"tag": d.get("tag") if isinstance(d.get("tag"), str) else None,
|
|
361
|
+
"etag": d.get("etag") if isinstance(d.get("etag"), str) else None,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _write_shared_cache(channel, version, tag, etag):
|
|
366
|
+
try:
|
|
367
|
+
os.makedirs(_state_dir(), exist_ok=True)
|
|
368
|
+
p = _shared_cache_path()
|
|
369
|
+
tmp = "%s.tmp.%d" % (p, os.getpid())
|
|
370
|
+
with open(tmp, "w") as f:
|
|
371
|
+
json.dump({"at": time.time(), "channel": channel,
|
|
372
|
+
"version": version, "tag": tag, "etag": etag}, f)
|
|
373
|
+
os.replace(tmp, p)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass # best effort; worst case another process re-probes
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _try_probe_lock():
|
|
379
|
+
"""Single-flight guard so N concurrent processes with an expired shared
|
|
380
|
+
cache don't all probe at once. Returns (acquired, lock_path). acquired is
|
|
381
|
+
False ONLY when another probe holds a FRESH lock (< _PROBE_LOCK_STALE s);
|
|
382
|
+
any other failure returns (True, None) so we probe anyway rather than go
|
|
383
|
+
blind on a broken state dir."""
|
|
384
|
+
lock = os.path.join(_state_dir(), "latest-release.lock")
|
|
385
|
+
try:
|
|
386
|
+
os.makedirs(_state_dir(), exist_ok=True)
|
|
387
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
388
|
+
os.close(fd)
|
|
389
|
+
return True, lock
|
|
390
|
+
except FileExistsError:
|
|
391
|
+
try:
|
|
392
|
+
if time.time() - os.path.getmtime(lock) > _PROBE_LOCK_STALE:
|
|
393
|
+
os.unlink(lock) # stale: holder died mid-probe
|
|
394
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
395
|
+
os.close(fd)
|
|
396
|
+
return True, lock
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
399
|
+
return False, None
|
|
400
|
+
except Exception:
|
|
401
|
+
return True, None
|
|
402
|
+
|
|
328
403
|
_RELEASES_LATEST_URL = "https://github.com/m13v/s4l/releases/latest"
|
|
329
404
|
_RELEASES_LATEST_API = "https://api.github.com/repos/m13v/s4l/releases/latest"
|
|
330
405
|
# Staging resolves the newest release OVERALL from the releases LIST, since
|
|
@@ -367,56 +442,64 @@ def _latest_from_github_redirect():
|
|
|
367
442
|
return None
|
|
368
443
|
|
|
369
444
|
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
# 200 per process
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
445
|
+
# Conditional-request state lives in the SHARED cache file (latest-release.json)
|
|
446
|
+
# so the ETag survives process boundaries: short-lived shell-outs used to pay a
|
|
447
|
+
# full 200 per process; now every probe sends If-None-Match and gets a free 304
|
|
448
|
+
# between releases.
|
|
449
|
+
def _curl_conditional(url, etag):
|
|
450
|
+
"""GET url with optional If-None-Match. Returns (status, new_etag, body)."""
|
|
451
|
+
args = ["/usr/bin/curl", "-sS", "-m", "10",
|
|
452
|
+
"-H", "Accept: application/vnd.github+json"]
|
|
453
|
+
if etag:
|
|
454
|
+
args += ["-H", "If-None-Match: %s" % etag]
|
|
455
|
+
args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url]
|
|
456
|
+
res = subprocess.run(args, capture_output=True, text=True, timeout=12)
|
|
457
|
+
status, new_etag, body = 0, None, []
|
|
458
|
+
for line in (res.stdout or "").splitlines():
|
|
459
|
+
if line.startswith("__CURL_STATUS__:"):
|
|
460
|
+
status = int(line.split(":", 1)[1].strip() or 0)
|
|
461
|
+
elif line.startswith("__CURL_ETAG__:"):
|
|
462
|
+
new_etag = line.split(":", 1)[1].strip() or None
|
|
463
|
+
else:
|
|
464
|
+
body.append(line)
|
|
465
|
+
return status, new_etag, "\n".join(body)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _latest_from_github_api(etag=None, cached=None):
|
|
469
|
+
"""Stable probe (releases/latest). Returns (version, etag). On 304 serves
|
|
470
|
+
the caller-supplied cached version with the same etag; If-None-Match is only
|
|
471
|
+
sent when there IS a cached value to serve."""
|
|
377
472
|
try:
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if _api_state["etag"]:
|
|
381
|
-
args += ["-H", "If-None-Match: %s" % _api_state["etag"]]
|
|
382
|
-
args += ["-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}",
|
|
383
|
-
_RELEASES_LATEST_API]
|
|
384
|
-
res = subprocess.run(args, capture_output=True, text=True, timeout=12)
|
|
385
|
-
status, etag, body = 0, None, []
|
|
386
|
-
for line in (res.stdout or "").splitlines():
|
|
387
|
-
if line.startswith("__CURL_STATUS__:"):
|
|
388
|
-
status = int(line.split(":", 1)[1].strip() or 0)
|
|
389
|
-
elif line.startswith("__CURL_ETAG__:"):
|
|
390
|
-
etag = line.split(":", 1)[1].strip() or None
|
|
391
|
-
else:
|
|
392
|
-
body.append(line)
|
|
473
|
+
status, new_etag, body = _curl_conditional(
|
|
474
|
+
_RELEASES_LATEST_API, etag if cached else None)
|
|
393
475
|
if status == 304:
|
|
394
|
-
return
|
|
476
|
+
return cached, etag
|
|
395
477
|
if status != 200:
|
|
396
|
-
return None
|
|
397
|
-
tag = (json.loads(
|
|
478
|
+
return None, None
|
|
479
|
+
tag = (json.loads(body) or {}).get("tag_name")
|
|
398
480
|
v = _parse_semverish(tag.lstrip("v").strip()) if isinstance(tag, str) else None
|
|
399
|
-
if v
|
|
400
|
-
_api_state.update(etag=etag, latest=v)
|
|
401
|
-
return v
|
|
481
|
+
return (v, new_etag) if v else (None, None)
|
|
402
482
|
except Exception:
|
|
403
|
-
return None
|
|
483
|
+
return None, None
|
|
404
484
|
|
|
405
485
|
|
|
406
|
-
def _latest_from_github_list_staging():
|
|
486
|
+
def _latest_from_github_list_staging(etag=None, cached=None):
|
|
407
487
|
"""Staging channel: newest release OVERALL (prereleases included) from the
|
|
408
|
-
releases LIST endpoint. Returns (version, tag) or (None, None).
|
|
409
|
-
skipped. 'Newest' is by the rc-aware key so 1.6.193 outranks
|
|
410
|
-
rc.2 outranks rc.1.
|
|
488
|
+
releases LIST endpoint. Returns (version, tag, etag) or (None, None, None).
|
|
489
|
+
Drafts are skipped. 'Newest' is by the rc-aware key so 1.6.193 outranks
|
|
490
|
+
1.6.193-rc.N and rc.2 outranks rc.1. On 304 serves the caller-supplied
|
|
491
|
+
cached (version, tag) with the same etag."""
|
|
411
492
|
try:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
493
|
+
cached_v, cached_tag = cached if isinstance(cached, tuple) else (None, None)
|
|
494
|
+
status, new_etag, body = _curl_conditional(
|
|
495
|
+
_RELEASES_LIST_API, etag if (cached_v and cached_tag) else None)
|
|
496
|
+
if status == 304:
|
|
497
|
+
return cached_v, cached_tag, etag
|
|
498
|
+
if status != 200:
|
|
499
|
+
return None, None, None
|
|
500
|
+
rels = json.loads(body or "[]")
|
|
418
501
|
if not isinstance(rels, list):
|
|
419
|
-
return None, None
|
|
502
|
+
return None, None, None
|
|
420
503
|
best_v, best_tag, best_key = None, None, None
|
|
421
504
|
for r in rels:
|
|
422
505
|
if not isinstance(r, dict) or r.get("draft"):
|
|
@@ -430,9 +513,11 @@ def _latest_from_github_list_staging():
|
|
|
430
513
|
k = _ver_key(v)
|
|
431
514
|
if best_key is None or k > best_key:
|
|
432
515
|
best_v, best_tag, best_key = v, tag, k
|
|
433
|
-
|
|
516
|
+
if best_v is None:
|
|
517
|
+
return None, None, None
|
|
518
|
+
return best_v, best_tag, new_etag
|
|
434
519
|
except Exception:
|
|
435
|
-
return None, None
|
|
520
|
+
return None, None, None
|
|
436
521
|
|
|
437
522
|
|
|
438
523
|
def _latest_from_npm():
|
|
@@ -460,31 +545,64 @@ def _resolve_version() -> str:
|
|
|
460
545
|
def _latest_published(channel=None):
|
|
461
546
|
"""(version, tag) for the newest release on this box's channel. The tag is
|
|
462
547
|
what the staging download URL is built from; stable callers can ignore it and
|
|
463
|
-
use releases/latest/download.
|
|
464
|
-
|
|
548
|
+
use releases/latest/download. Two cache layers, both keyed by channel so a
|
|
549
|
+
mid-process flip re-probes instead of serving the other channel's value:
|
|
550
|
+
1. in-process _ver_cache (55s) — spares the file read on tight tick loops;
|
|
551
|
+
2. shared latest-release.json (_SHARED_TTL) — spares the NETWORK probe
|
|
552
|
+
across ALL processes on the box.
|
|
553
|
+
Failures (latest=None) are cached in both, like version.ts: a tick loop
|
|
554
|
+
re-probing an unreachable/rate-limited GitHub every few seconds would burn
|
|
555
|
+
the unauthenticated API quota (60/h per IP) and lock itself out for good."""
|
|
465
556
|
if channel is None:
|
|
466
557
|
channel = _channel()
|
|
467
558
|
now = time.time()
|
|
468
|
-
# Cache failures (latest=None) too, like version.ts: a menu-bar tick loop
|
|
469
|
-
# re-probing an unreachable/rate-limited GitHub every few seconds would burn
|
|
470
|
-
# the unauthenticated API quota (60/h per IP) and lock itself out for good.
|
|
471
559
|
if (_ver_cache["checked"] and _ver_cache["channel"] == channel
|
|
472
560
|
and now - _ver_cache["at"] < _VER_TTL):
|
|
473
561
|
return _ver_cache["latest"], _ver_cache["tag"]
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
562
|
+
shared = _read_shared_cache(channel)
|
|
563
|
+
if shared and now - shared["at"] < _SHARED_TTL:
|
|
564
|
+
_ver_cache.update(at=now, latest=shared["version"], tag=shared["tag"],
|
|
565
|
+
channel=channel, checked=True)
|
|
566
|
+
return shared["version"], shared["tag"]
|
|
567
|
+
acquired, lock = _try_probe_lock()
|
|
568
|
+
if not acquired:
|
|
569
|
+
# Another process is probing right now; serve the stale shared value
|
|
570
|
+
# (or None) instead of doubling the request. Short in-process TTL means
|
|
571
|
+
# we pick up its fresh result within a minute.
|
|
572
|
+
latest = shared["version"] if shared else None
|
|
573
|
+
tag = shared["tag"] if shared else None
|
|
574
|
+
_ver_cache.update(at=now, latest=latest, tag=tag, channel=channel, checked=True)
|
|
575
|
+
return latest, tag
|
|
576
|
+
etag = shared["etag"] if shared else None
|
|
577
|
+
try:
|
|
578
|
+
if channel == "staging":
|
|
579
|
+
latest, tag, new_etag = _latest_from_github_list_staging(
|
|
580
|
+
etag, (shared["version"], shared["tag"]) if shared else None)
|
|
581
|
+
# Fallback to the stable probes if the list endpoint fails, so a
|
|
582
|
+
# staging box degrades to "at least track stable" than going blind.
|
|
583
|
+
# new_etag stays None: a releases/latest etag must never be replayed
|
|
584
|
+
# against the LIST endpoint on the next staging probe.
|
|
585
|
+
if latest is None:
|
|
586
|
+
latest, _ = _latest_from_github_api()
|
|
587
|
+
new_etag = None
|
|
588
|
+
if latest is None:
|
|
589
|
+
latest = _latest_from_github_redirect()
|
|
590
|
+
tag = ("v" + latest) if latest else None
|
|
591
|
+
else:
|
|
592
|
+
latest, new_etag = _latest_from_github_api(
|
|
593
|
+
etag, shared["version"] if shared else None)
|
|
594
|
+
if latest is None:
|
|
595
|
+
latest, new_etag = _latest_from_github_redirect(), None
|
|
596
|
+
if latest is None:
|
|
597
|
+
latest = _latest_from_npm()
|
|
480
598
|
tag = ("v" + latest) if latest else None
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
599
|
+
_write_shared_cache(channel, latest, tag, new_etag)
|
|
600
|
+
finally:
|
|
601
|
+
if lock:
|
|
602
|
+
try:
|
|
603
|
+
os.unlink(lock)
|
|
604
|
+
except Exception:
|
|
605
|
+
pass
|
|
488
606
|
_ver_cache.update(at=now, latest=latest, tag=tag, channel=channel, checked=True)
|
|
489
607
|
return latest, tag
|
|
490
608
|
|
|
@@ -333,25 +333,39 @@ ensure_linkedin_browser_for_backend() {
|
|
|
333
333
|
# The occlusion/backgrounding flags matter: the window sits offscreen,
|
|
334
334
|
# and without them Chrome stops laying out SPA-rendered content, so
|
|
335
335
|
# every element measures 0x0 and clicks become impossible (2026-07-03).
|
|
336
|
-
#
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
336
|
+
# Launch flags shared by both spawn paths below.
|
|
337
|
+
local _li_launch_args=(
|
|
338
|
+
--remote-debugging-port=9556
|
|
339
|
+
--user-data-dir="$HOME/.claude/browser-profiles/browser-harness-linkedin"
|
|
340
|
+
--no-first-run --no-default-browser-check
|
|
341
|
+
--disable-features=ChromeWhatsNewUI
|
|
342
|
+
--disable-backgrounding-occluded-windows
|
|
343
|
+
--disable-renderer-backgrounding
|
|
344
|
+
--disable-background-timer-throttling
|
|
345
|
+
"${_extra[@]}"
|
|
346
|
+
about:blank
|
|
347
|
+
)
|
|
348
|
+
# macOS + .app-bundled Chrome: `open -n -g` launches a NEW instance
|
|
349
|
+
# WITHOUT stealing the user's focus (a directly-exec'd Chrome always
|
|
350
|
+
# activates itself), and LaunchServices parents it outside this launchd
|
|
351
|
+
# job's process group. Same fix + rationale as twitter-backend.sh
|
|
352
|
+
# (2026-07-13). Fallback: direct exec in a new session — os.setsid so
|
|
353
|
+
# Chrome escapes the job's process group; launchd SIGKILLs a transient
|
|
354
|
+
# job's whole pgroup when the shell exits, and `disown` does not change
|
|
355
|
+
# the pgid (2026-07-12).
|
|
356
|
+
local _li_app_bundle=""
|
|
357
|
+
case "$_chrome_bin" in
|
|
358
|
+
*.app/Contents/MacOS/*) _li_app_bundle="${_chrome_bin%%/Contents/MacOS/*}" ;;
|
|
359
|
+
esac
|
|
360
|
+
if [ "$(uname -s)" = "Darwin" ] && [ -n "$_li_app_bundle" ] && [ -d "$_li_app_bundle" ]; then
|
|
361
|
+
open -n -g -a "$_li_app_bundle" --args "${_li_launch_args[@]}" >/dev/null 2>&1 || true
|
|
362
|
+
else
|
|
363
|
+
"${S4L_PYTHON:-python3}" -c 'import os,sys
|
|
342
364
|
os.setsid()
|
|
343
365
|
os.execv(sys.argv[1], sys.argv[1:])' \
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
--no-first-run --no-default-browser-check \
|
|
348
|
-
--disable-features=ChromeWhatsNewUI \
|
|
349
|
-
--disable-backgrounding-occluded-windows \
|
|
350
|
-
--disable-renderer-backgrounding \
|
|
351
|
-
--disable-background-timer-throttling \
|
|
352
|
-
"${_extra[@]}" \
|
|
353
|
-
about:blank >/dev/null 2>&1 &
|
|
354
|
-
disown
|
|
366
|
+
"$_chrome_bin" "${_li_launch_args[@]}" >/dev/null 2>&1 &
|
|
367
|
+
disown
|
|
368
|
+
fi
|
|
355
369
|
for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
|
356
370
|
curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null && break
|
|
357
371
|
sleep 1
|
|
@@ -309,27 +309,47 @@ ensure_twitter_browser_for_backend() {
|
|
|
309
309
|
[ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
|
|
310
310
|
rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
|
|
311
311
|
fi
|
|
312
|
-
#
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
312
|
+
# Launch flags shared by both spawn paths below.
|
|
313
|
+
local _launch_args=(
|
|
314
|
+
--remote-debugging-port=9555
|
|
315
|
+
--user-data-dir="$HOME/.claude/browser-profiles/browser-harness"
|
|
316
|
+
--no-first-run --no-default-browser-check
|
|
317
|
+
--password-store=basic --use-mock-keychain
|
|
318
|
+
--disable-features=ChromeWhatsNewUI,CalculateNativeWinOcclusion
|
|
319
|
+
--disable-backgrounding-occluded-windows
|
|
320
|
+
"${_extra[@]}"
|
|
321
|
+
"${BH_LAUNCH_URL:-https://x.com}"
|
|
322
|
+
)
|
|
323
|
+
# macOS + .app-bundled Chrome: launch via `open -n -g` so the window
|
|
324
|
+
# NEVER steals the user's focus. A directly-exec'd Chrome always
|
|
325
|
+
# activates itself on launch — every legitimate relaunch (wedge heal,
|
|
326
|
+
# user quit, crash) popped a window over the user's work (the
|
|
327
|
+
# foreground-telemetry `cause:launched` events, 2026-07-13). -n forces
|
|
328
|
+
# a NEW instance (the user's personal Chrome may already be running;
|
|
329
|
+
# without -n LaunchServices would just poke it and drop our --args),
|
|
330
|
+
# -g launches WITHOUT activation. LaunchServices also parents the
|
|
331
|
+
# process outside this launchd job's process group, which independently
|
|
332
|
+
# covers the 2026-07-12 pgroup-reaping bug the setsid fallback fixes.
|
|
333
|
+
local _app_bundle=""
|
|
334
|
+
case "$_chrome_bin" in
|
|
335
|
+
*.app/Contents/MacOS/*) _app_bundle="${_chrome_bin%%/Contents/MacOS/*}" ;;
|
|
336
|
+
esac
|
|
337
|
+
if [ "$(uname -s)" = "Darwin" ] && [ -n "$_app_bundle" ] && [ -d "$_app_bundle" ]; then
|
|
338
|
+
open -n -g -a "$_app_bundle" --args "${_launch_args[@]}" >/dev/null 2>&1 || true
|
|
339
|
+
else
|
|
340
|
+
# Fallback (Linux, bare chromium binaries): direct exec in a NEW
|
|
341
|
+
# SESSION. os.setsid: Chrome must escape THIS job's process group —
|
|
342
|
+
# launchd SIGKILLs a transient job's whole process group the moment
|
|
343
|
+
# the shell exits (no AbandonProcessGroup), and `disown` does not
|
|
344
|
+
# change the pgid, so a plainly-backgrounded Chrome died on every
|
|
345
|
+
# cycle completion and the NEXT cycle's relaunch stole the user's
|
|
346
|
+
# focus (2026-07-12 root cause).
|
|
347
|
+
"${S4L_PYTHON:-python3}" -c 'import os,sys
|
|
321
348
|
os.setsid()
|
|
322
349
|
os.execv(sys.argv[1], sys.argv[1:])' \
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
--no-first-run --no-default-browser-check \
|
|
327
|
-
--password-store=basic --use-mock-keychain \
|
|
328
|
-
--disable-features=ChromeWhatsNewUI,CalculateNativeWinOcclusion \
|
|
329
|
-
--disable-backgrounding-occluded-windows \
|
|
330
|
-
"${_extra[@]}" \
|
|
331
|
-
"${BH_LAUNCH_URL:-https://x.com}" >/dev/null 2>&1 &
|
|
332
|
-
disown
|
|
350
|
+
"$_chrome_bin" "${_launch_args[@]}" >/dev/null 2>&1 &
|
|
351
|
+
disown
|
|
352
|
+
fi
|
|
333
353
|
for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
|
334
354
|
curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null && break
|
|
335
355
|
sleep 1
|
|
@@ -1878,7 +1878,7 @@ if [ "$S4L_DRAFT_PROMPT_VARIANT" = "treatment_v3" ]; then
|
|
|
1878
1878
|
else
|
|
1879
1879
|
DRAFT_DIRECTIVE="Otherwise: draft a reply using the best engagement style. Length is governed ENTIRELY by the per-style LENGTH LIMIT in the style block above; obey that target and ceiling, do not apply any other length rule here. NEVER em dashes. Apply the matched project's \`voice\` block from ALL_PROJECTS_JSON: follow voice.tone, never violate voice.never, mirror voice.examples / voice.examples_good when present. The matched project's learned_preferences block in ALL_PROJECTS_JSON is distilled human review feedback and is MANDATORY, not advisory: follow every learned_preferences.draft_style_notes entry when writing (it overrides the engagement style's structural template on conflict), and treat learned_preferences.audience_avoid / thread_avoid matches as strong reasons to skip the candidate. Never violate content_guardrails.do_not."
|
|
1880
1880
|
fi
|
|
1881
|
-
# Personal-brand lane (S4L_ACTIVE_LANE=personal_brand, set by
|
|
1881
|
+
# Personal-brand lane (S4L_ACTIVE_LANE=personal_brand, set by s4l_mode.py):
|
|
1882
1882
|
# replace the product-framed directive entirely. This lane is pure organic
|
|
1883
1883
|
# growth: no product, no link, no CTA. The reply must add real value grounded in
|
|
1884
1884
|
# the persona's first-hand material (the PERSONA CORPUS block + the persona voice
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import json, os, sys, time, subprocess, signal
|
|
2
|
-
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
|
3
|
-
|
|
4
|
-
LOCK = os.path.expanduser("~/.claude/twitter-browser-lock.json")
|
|
5
|
-
os.makedirs(os.path.dirname(LOCK), exist_ok=True)
|
|
6
|
-
|
|
7
|
-
def write_lock(pid, role):
|
|
8
|
-
with open(LOCK, "w") as f:
|
|
9
|
-
json.dump({"session_id": f"python:{pid}", "timestamp": int(time.time()), "role": role}, f)
|
|
10
|
-
|
|
11
|
-
def clean():
|
|
12
|
-
try: os.remove(LOCK)
|
|
13
|
-
except OSError: pass
|
|
14
|
-
|
|
15
|
-
# ---- TEST 1: poster preempts a LIVE scan holder ----
|
|
16
|
-
clean()
|
|
17
|
-
victim = subprocess.Popen(["sleep", "120"])
|
|
18
|
-
write_lock(victim.pid, "scan")
|
|
19
|
-
os.environ["SAPS_LOCK_ROLE"] = "post"
|
|
20
|
-
import importlib, twitter_browser
|
|
21
|
-
importlib.reload(twitter_browser)
|
|
22
|
-
t0 = time.time()
|
|
23
|
-
twitter_browser._acquire_browser_lock()
|
|
24
|
-
dt = time.time() - t0
|
|
25
|
-
held = json.load(open(LOCK))
|
|
26
|
-
victim_alive = victim.poll() is None
|
|
27
|
-
print(f"TEST1 poster-preempts-scan: took={dt:.1f}s holder_now={held['session_id']} role={held.get('role')} victim_alive={victim_alive}")
|
|
28
|
-
assert held["session_id"] == f"python:{os.getpid()}", "poster should hold lock"
|
|
29
|
-
assert held.get("role") == "post"
|
|
30
|
-
assert not victim_alive, "scan victim should be killed"
|
|
31
|
-
assert dt < 10, "should preempt fast, not wait 45s"
|
|
32
|
-
if victim.poll() is None: victim.kill()
|
|
33
|
-
print("TEST1 PASS")
|
|
34
|
-
|
|
35
|
-
# ---- TEST 2: scanner does NOT preempt a live POST holder (waits then gives up) ----
|
|
36
|
-
clean()
|
|
37
|
-
poster = subprocess.Popen(["sleep", "120"])
|
|
38
|
-
write_lock(poster.pid, "post")
|
|
39
|
-
os.environ["SAPS_LOCK_ROLE"] = "scan"
|
|
40
|
-
twitter_browser.LOCK_WAIT_MAX = 4 # shorten the give-up window for the test
|
|
41
|
-
twitter_browser.LOCK_ROLE = "scan"
|
|
42
|
-
twitter_browser._LOCK_SESSION_ID = f"python:{os.getpid()}"
|
|
43
|
-
twitter_browser._LOCK_INHERITED = False
|
|
44
|
-
import io, contextlib
|
|
45
|
-
t0 = time.time()
|
|
46
|
-
rc = None
|
|
47
|
-
try:
|
|
48
|
-
twitter_browser._acquire_browser_lock()
|
|
49
|
-
rc = "acquired"
|
|
50
|
-
except SystemExit as e:
|
|
51
|
-
rc = f"exit:{e.code}"
|
|
52
|
-
dt = time.time() - t0
|
|
53
|
-
poster_alive = poster.poll() is None
|
|
54
|
-
print(f"TEST2 scanner-vs-post: result={rc} took={dt:.1f}s poster_alive={poster_alive}")
|
|
55
|
-
assert rc == "exit:1", "scanner must NOT take a live post holder; should give up"
|
|
56
|
-
assert poster_alive, "scanner must NOT kill the poster"
|
|
57
|
-
if poster.poll() is None: poster.kill()
|
|
58
|
-
print("TEST2 PASS")
|
|
59
|
-
clean()
|
|
60
|
-
print("ALL PASS")
|
package/scripts/saps_activity.py
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""LEGACY-NAME SHIM (brand rename SAPS -> S4L, completed 2026-07-06).
|
|
3
|
-
|
|
4
|
-
The real module is scripts/s4l_activity.py. This shim exists ONLY for callers
|
|
5
|
-
that cannot be edited (chflags-locked pipeline scripts) and for older installed
|
|
6
|
-
runtimes. Do NOT add new references to this filename; import or invoke
|
|
7
|
-
s4l_activity.py directly.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
import sys
|
|
12
|
-
|
|
13
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
-
from s4l_activity import * # noqa: F401,F403,E402
|
|
15
|
-
from s4l_activity import _main # noqa: E402
|
|
16
|
-
|
|
17
|
-
if __name__ == "__main__":
|
|
18
|
-
raise SystemExit(_main(sys.argv[1:]))
|
package/scripts/saps_mode.py
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""LEGACY-NAME SHIM (brand rename SAPS -> S4L, completed 2026-07-06).
|
|
3
|
-
|
|
4
|
-
The real module is scripts/s4l_mode.py. This shim exists ONLY for callers that
|
|
5
|
-
cannot be edited (the chflags-locked run-twitter-cycle.sh) and for older
|
|
6
|
-
installed runtimes. Do NOT add new references to this filename; import or
|
|
7
|
-
invoke s4l_mode.py directly.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
import sys
|
|
12
|
-
|
|
13
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
-
from s4l_mode import * # noqa: F401,F403,E402
|
|
15
|
-
from s4l_mode import main # noqa: E402
|
|
16
|
-
|
|
17
|
-
if __name__ == "__main__":
|
|
18
|
-
raise SystemExit(main(sys.argv[1:]))
|