@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.
@@ -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) {
@@ -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
- // In-process conditional-request state: long-lived processes send If-None-Match
132
- // on every probe and get free 304s; each new release costs a single 200.
133
- const apiState = { etag: null, latest: null };
134
- async function latestFromGithubApi() {
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 args = ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json"];
137
- if (apiState.etag)
138
- args.push("-H", `If-None-Match: ${apiState.etag}`);
139
- args.push("-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", RELEASES_LATEST_API);
140
- const res = await run("curl", args, { timeoutMs: 12000, noTee: true });
141
- let status = 0;
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
- if (v) {
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. Returns {version, tag} or null.
181
- async function latestFromGithubListStaging() {
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 res = await run("curl", ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json", RELEASES_LIST_API], { timeoutMs: 12000, noTee: true });
184
- const rels = JSON.parse(res.stdout || "[]");
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 r of rels) {
189
- if (!r || typeof r !== "object" || r.draft)
259
+ for (const rel of rels) {
260
+ if (!rel || typeof rel !== "object" || rel.draft)
190
261
  continue;
191
- const tag = r.tag_name;
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
- if (channel === "staging") {
227
- const s = await latestFromGithubListStaging();
228
- if (s) {
229
- version = s.version;
230
- tag = s.tag;
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
- // Degrade to the stable probes so a staging box tracks at least stable
234
- // rather than going blind when the list endpoint fails.
235
- version = (await latestFromGithubApi()) ?? (await latestFromGithubRedirect());
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
- else {
240
- version = await latestFromGithubApi();
241
- if (version == null)
242
- version = await latestFromGithubRedirect();
243
- if (version == null)
244
- version = await latestFromNpm();
245
- tag = version ? `v${version}` : null;
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 };
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.32",
3
- "installedAt": "2026-07-13T22:36:08.709Z"
2
+ "version": "1.7.4-rc.34",
3
+ "installedAt": "2026-07-13T22:53:49.281Z"
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.7.4-rc.32",
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; this read runs
87
- # BEFORE scripts/ is on sys.path, so the s4l_env mirror can't help yet.
88
- _repo = os.environ.get("S4L_REPO_DIR")
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:
@@ -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.32",
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.32",
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
  )"
@@ -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
- Before the 2026-07-06 SAPS->S4L rename this file lived at scripts/saps_mode.py;
74
- a thin shim keeps that old path alive for the chflags-locked
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 sapsStateDir() and menubar/s4l_state.py state_dir().
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")
@@ -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
- # In-process conditional-request state. Long-lived processes (the menu bar) send
371
- # If-None-Match on every probe and get free 304s; short-lived shell-outs pay one
372
- # 200 per process, which is rare enough to stay far under quota.
373
- _api_state = {"etag": None, "latest": None}
374
-
375
-
376
- def _latest_from_github_api():
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
- args = ["/usr/bin/curl", "-sS", "-m", "10",
379
- "-H", "Accept: application/vnd.github+json"]
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 _api_state["latest"]
476
+ return cached, etag
395
477
  if status != 200:
396
- return None
397
- tag = (json.loads("\n".join(body)) or {}).get("tag_name")
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). Drafts are
409
- skipped. 'Newest' is by the rc-aware key so 1.6.193 outranks 1.6.193-rc.N and
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
- res = subprocess.run(
413
- ["/usr/bin/curl", "-sS", "-m", "10",
414
- "-H", "Accept: application/vnd.github+json",
415
- _RELEASES_LIST_API],
416
- capture_output=True, text=True, timeout=12)
417
- rels = json.loads(res.stdout or "[]")
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
- return best_v, best_tag
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. Cached with the channel so a mid-process
464
- channel flip re-probes instead of serving the other channel's cached value."""
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
- if channel == "staging":
475
- latest, tag = _latest_from_github_list_staging()
476
- # Fallback to the stable probes if the list endpoint fails, so a staging
477
- # box degrades to "at least track stable" rather than going blind.
478
- if latest is None:
479
- latest = _latest_from_github_api() or _latest_from_github_redirect()
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
- else:
482
- latest = _latest_from_github_api()
483
- if latest is None:
484
- latest = _latest_from_github_redirect()
485
- if latest is None:
486
- latest = _latest_from_npm()
487
- tag = ("v" + latest) if latest else None
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
- # os.setsid: Chrome must escape THIS job's process group — launchd
337
- # SIGKILLs a transient job's whole process group when the shell exits,
338
- # and `disown` does not change the pgid, so Chrome died with every
339
- # completed run and the next lane's relaunch stole the user's focus
340
- # (2026-07-12; same fix as twitter-backend.sh).
341
- "${S4L_PYTHON:-python3}" -c 'import os,sys
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
- "$_chrome_bin" \
345
- --remote-debugging-port=9556 \
346
- --user-data-dir="$HOME/.claude/browser-profiles/browser-harness-linkedin" \
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
- # os.setsid: Chrome must escape THIS job's process group. The kicker is a
313
- # transient launchd job, and launchd SIGKILLs the job's whole process
314
- # group the moment the shell exits (no AbandonProcessGroup) — `disown`
315
- # does not change the pgid, so a plainly-backgrounded Chrome died on
316
- # every cycle completion and the NEXT cycle's relaunch stole the user's
317
- # focus (2026-07-12 root cause; the foreground-telemetry `cause:launched`
318
- # loop). A new session makes Chrome survive its launcher regardless of
319
- # which lane spawned it.
320
- "${S4L_PYTHON:-python3}" -c 'import os,sys
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
- "$_chrome_bin" \
324
- --remote-debugging-port=9555 \
325
- --user-data-dir="$HOME/.claude/browser-profiles/browser-harness" \
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 saps_mode.py):
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")
@@ -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:]))
@@ -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:]))