@m13v/s4l 1.7.12-rc.6 → 1.7.12-rc.8
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/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +24 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/harness_overlay.py +84 -39
- package/scripts/s4l_box_update.sh +25 -4
- package/scripts/schedule_state.py +9 -5
- package/skill/run-twitter-cycle.sh +6 -0
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.12-rc.
|
|
5
|
+
"version": "1.7.12-rc.8",
|
|
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": {
|
|
@@ -1690,6 +1690,30 @@ class S4LMenuBar(rumps.App):
|
|
|
1690
1690
|
verification on such machines.
|
|
1691
1691
|
"""
|
|
1692
1692
|
app_support = os.path.expanduser("~/Library/Application Support")
|
|
1693
|
+
# LIVE profile first (2026-09-28): on a multi-profile machine (account
|
|
1694
|
+
# rotator: Claude, Claude-mediar, Claude-mddy, ...) every profile keeps
|
|
1695
|
+
# its OWN copy of the extension, and "newest mtime" is simply the
|
|
1696
|
+
# profile that was updated most recently, not the one Claude is
|
|
1697
|
+
# running right now. The operator Mac sat on 1.7.7-rc.4 in the live
|
|
1698
|
+
# Claude-mediar profile for two months while every update landed in
|
|
1699
|
+
# Claude-mddy, and the updater kept reporting "already on latest".
|
|
1700
|
+
# Prefer the extension dir under a RUNNING Claude's --user-data-dir
|
|
1701
|
+
# (default profile == plain "Claude"); fall back to the newest-mtime
|
|
1702
|
+
# scan only when no Claude instance is running. Mirrors
|
|
1703
|
+
# scripts/s4l_box_update.sh; keep the two in sync.
|
|
1704
|
+
for udd in S4LMenuBar._claude_user_data_dirs():
|
|
1705
|
+
root = os.path.join(udd or os.path.join(app_support, "Claude"),
|
|
1706
|
+
"Claude Extensions")
|
|
1707
|
+
try:
|
|
1708
|
+
names = sorted(os.listdir(root))
|
|
1709
|
+
except OSError:
|
|
1710
|
+
continue
|
|
1711
|
+
for name in names:
|
|
1712
|
+
if not name.endswith("social-autoposter"):
|
|
1713
|
+
continue
|
|
1714
|
+
d = os.path.join(root, name)
|
|
1715
|
+
if os.path.exists(os.path.join(d, "manifest.json")):
|
|
1716
|
+
return d
|
|
1693
1717
|
best, best_mtime = None, -1.0
|
|
1694
1718
|
for root in glob.glob(os.path.join(app_support, "Claude*", "Claude Extensions")):
|
|
1695
1719
|
try:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.12-rc.
|
|
3
|
+
"version": "1.7.12-rc.8",
|
|
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
|
@@ -13,8 +13,10 @@ Design constraints, deliberately:
|
|
|
13
13
|
own clicks. It is purely cosmetic.
|
|
14
14
|
- CSP-safe: the overlay is built with createElement + element.style.<prop> +
|
|
15
15
|
textContent only. No innerHTML-with-style-attributes and no injected <style>
|
|
16
|
-
tag, both of which x.com's CSP can refuse.
|
|
17
|
-
|
|
16
|
+
tag, both of which x.com's CSP can refuse.
|
|
17
|
+
- Static: no timers, no animation, no backdrop blur. The harness window is
|
|
18
|
+
kept painting even when hidden (--disable-backgrounding-occluded-windows),
|
|
19
|
+
so anything animated here redraws at display rate around the clock.
|
|
18
20
|
- Survives navigation two ways: (1) Playwright add_init_script registers the
|
|
19
21
|
builder on the browser context so every new document re-creates it, and
|
|
20
22
|
(2) the watch loop re-asserts it via evaluate every couple seconds.
|
|
@@ -29,15 +31,14 @@ the locked pipeline scripts. Drive it from the CLI:
|
|
|
29
31
|
# log into the overlay
|
|
30
32
|
|
|
31
33
|
`watch` is the always-on mode: it tails the newest skill/logs/twitter-cycle-*.log
|
|
32
|
-
and
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
and repaints the overlay only when the status line changes or a page
|
|
35
|
+
navigates. If the harness Chrome is down or has no http tab it backs off and
|
|
36
|
+
retries; it never crashes the pipeline.
|
|
35
37
|
"""
|
|
36
38
|
|
|
37
39
|
from __future__ import annotations
|
|
38
40
|
|
|
39
41
|
import fcntl
|
|
40
|
-
import glob
|
|
41
42
|
import json
|
|
42
43
|
import os
|
|
43
44
|
import re
|
|
@@ -86,9 +87,7 @@ REASSURE = (
|
|
|
86
87
|
|
|
87
88
|
# --- the page-side overlay builder ------------------------------------------
|
|
88
89
|
# `_BODY` defines window.__s4lPaint(payload): idempotently creates the overlay
|
|
89
|
-
# DOM, then updates its text.
|
|
90
|
-
# "updated Ns ago" ticker so the overlay always looks alive between status
|
|
91
|
-
# pushes. Built with createElement + element.style.<prop> + textContent only
|
|
90
|
+
# DOM, then updates its text. Built with createElement + element.style.<prop> + textContent only
|
|
92
91
|
# (CSP-safe; no <style> tag, no innerHTML-with-style-attrs). pointer-events is
|
|
93
92
|
# none so the overlay can never intercept the automation's own clicks.
|
|
94
93
|
_BODY = r"""
|
|
@@ -114,7 +113,6 @@ window.__s4lAnnounce = function(payload){
|
|
|
114
113
|
bs.zIndex="2147483647"; bs.display="flex";
|
|
115
114
|
bs.alignItems="center"; bs.justifyContent="center";
|
|
116
115
|
bs.background="rgba(0,0,0,0.55)";
|
|
117
|
-
bs.backdropFilter="blur(3px)"; bs.webkitBackdropFilter="blur(3px)";
|
|
118
116
|
// The ENTIRE modal (backdrop + card + text) is pointer-events:none so that a
|
|
119
117
|
// bot click during this one-time window always passes through to the page,
|
|
120
118
|
// even if the user never clicks OK. The OK button is the ONLY element that
|
|
@@ -184,7 +182,6 @@ window.__s4lPaint = function(payload){
|
|
|
184
182
|
s.background="rgba(15,15,17,0.92)"; s.color="#fff";
|
|
185
183
|
s.font="13px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif";
|
|
186
184
|
s.boxShadow="0 6px 22px rgba(0,0,0,0.35)"; s.border="1px solid rgba(255,255,255,0.12)";
|
|
187
|
-
s.backdropFilter="blur(6px)"; s.webkitBackdropFilter="blur(6px)";
|
|
188
185
|
|
|
189
186
|
var head = mk("div", root); head.style.display="flex"; head.style.alignItems="center"; head.style.gap="8px";
|
|
190
187
|
head.style.cursor="move"; head.style.userSelect="none"; head.style.webkitUserSelect="none";
|
|
@@ -225,19 +222,13 @@ window.__s4lPaint = function(payload){
|
|
|
225
222
|
document.addEventListener("mouseup", function(){ drag = null; });
|
|
226
223
|
})();
|
|
227
224
|
|
|
228
|
-
if(st._iv) clearInterval(st._iv);
|
|
229
|
-
st._iv = setInterval(function(){
|
|
230
|
-
try{
|
|
231
|
-
var dt = Math.max(0, Math.round((Date.now()-st.ts)/1000));
|
|
232
|
-
st._ago.textContent = dt < 1 ? "now" : (dt < 60 ? dt+"s ago" : Math.round(dt/60)+"m ago");
|
|
233
|
-
var stale = dt > 90; // fade the dot once activity goes quiet
|
|
234
|
-
var phase = (Date.now()/650) % 2;
|
|
235
|
-
st._dot.style.opacity = stale ? "0.3" : (phase < 1 ? "1" : "0.35");
|
|
236
|
-
}catch(e){}
|
|
237
|
-
}, 250);
|
|
238
225
|
}
|
|
226
|
+
// Documents painted by an older overlay version still run its 250ms ticker.
|
|
227
|
+
if(st._iv){ clearInterval(st._iv); st._iv = null; st._dot.style.opacity = "1"; }
|
|
239
228
|
st._title.textContent = st.title;
|
|
240
229
|
st._status.textContent = st.status;
|
|
230
|
+
var d = new Date(st.ts);
|
|
231
|
+
st._ago.textContent = "since " + ("0"+d.getHours()).slice(-2) + ":" + ("0"+d.getMinutes()).slice(-2);
|
|
241
232
|
} catch(e) { /* overlay is best-effort, never throw into the page */ }
|
|
242
233
|
};
|
|
243
234
|
"""
|
|
@@ -277,7 +268,14 @@ class Harness:
|
|
|
277
268
|
def __enter__(self):
|
|
278
269
|
from playwright.sync_api import sync_playwright
|
|
279
270
|
self._pw = sync_playwright().start()
|
|
280
|
-
|
|
271
|
+
try:
|
|
272
|
+
self._browser = self._pw.chromium.connect_over_cdp(self.cdp_url, timeout=5000)
|
|
273
|
+
except Exception:
|
|
274
|
+
# A started-but-unstopped Playwright leaks its node driver and leaves
|
|
275
|
+
# its asyncio loop running, so the next start() fails with "Sync API
|
|
276
|
+
# inside the asyncio loop".
|
|
277
|
+
self.__exit__(None, None, None)
|
|
278
|
+
raise
|
|
281
279
|
return self
|
|
282
280
|
|
|
283
281
|
def __exit__(self, *exc):
|
|
@@ -292,6 +290,12 @@ class Harness:
|
|
|
292
290
|
except Exception:
|
|
293
291
|
pass
|
|
294
292
|
|
|
293
|
+
def connected(self) -> bool:
|
|
294
|
+
try:
|
|
295
|
+
return bool(self._browser) and self._browser.is_connected()
|
|
296
|
+
except Exception:
|
|
297
|
+
return False
|
|
298
|
+
|
|
295
299
|
def _pages(self):
|
|
296
300
|
pages = []
|
|
297
301
|
for ctx in self._browser.contexts:
|
|
@@ -323,10 +327,12 @@ class Harness:
|
|
|
323
327
|
except Exception:
|
|
324
328
|
pass
|
|
325
329
|
|
|
326
|
-
def paint(self, title: str, reassure: str, status: str) -> int:
|
|
327
|
-
"""Paint/refresh the overlay on every live page. Returns pages touched.
|
|
330
|
+
def paint(self, title: str, reassure: str, status: str, ts_ms: int | None = None) -> int:
|
|
331
|
+
"""Paint/refresh the overlay on every live page. Returns pages touched.
|
|
332
|
+
ts_ms is when the status last changed (defaults to now)."""
|
|
328
333
|
n = 0
|
|
329
|
-
payload = {"title": title, "reassure": reassure, "status": status,
|
|
334
|
+
payload = {"title": title, "reassure": reassure, "status": status,
|
|
335
|
+
"ts": ts_ms or int(time.time() * 1000)}
|
|
330
336
|
for p in self._pages():
|
|
331
337
|
try:
|
|
332
338
|
p.evaluate(PAINT_EXPR, payload)
|
|
@@ -363,14 +369,35 @@ def _safe_mtime(p: str) -> float:
|
|
|
363
369
|
return 0.0
|
|
364
370
|
|
|
365
371
|
|
|
372
|
+
_LOG_NAME_RE = re.compile(r"^twitter-cycle-\d{4}-\d{2}-\d{2}_\d{6}\.log$")
|
|
373
|
+
_log_cache: dict = {"dir_mtime": None, "newest": None}
|
|
374
|
+
|
|
375
|
+
|
|
366
376
|
def _latest_cycle_log() -> Path | None:
|
|
367
|
-
|
|
368
|
-
|
|
377
|
+
# LOG_DIR holds tens of thousands of cycle logs, so stat-ing each one every
|
|
378
|
+
# tick is expensive. Names embed the start time (lexicographic max = newest),
|
|
379
|
+
# and the directory's mtime changes only when a log is created or removed,
|
|
380
|
+
# so rescan only then.
|
|
381
|
+
try:
|
|
382
|
+
dir_mtime = os.stat(LOG_DIR).st_mtime
|
|
383
|
+
except OSError:
|
|
369
384
|
return None
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
385
|
+
if dir_mtime != _log_cache["dir_mtime"]:
|
|
386
|
+
newest = None
|
|
387
|
+
try:
|
|
388
|
+
with os.scandir(LOG_DIR) as it:
|
|
389
|
+
for e in it:
|
|
390
|
+
if _LOG_NAME_RE.match(e.name) and (newest is None or e.name > newest):
|
|
391
|
+
newest = e.name
|
|
392
|
+
except OSError:
|
|
393
|
+
return None
|
|
394
|
+
_log_cache.update(dir_mtime=dir_mtime, newest=newest)
|
|
395
|
+
if not _log_cache["newest"]:
|
|
396
|
+
return None
|
|
397
|
+
# The winner could have been deleted since the scan; the caller stats it
|
|
398
|
+
# again, so hand back None if it's gone.
|
|
399
|
+
p = LOG_DIR / _log_cache["newest"]
|
|
400
|
+
return p if p.exists() else None
|
|
374
401
|
|
|
375
402
|
|
|
376
403
|
_RE_SCAN = re.compile(r"project='([^']+)'\s+q=(['\"])(.*?)\2\s+kept=(\d+)")
|
|
@@ -391,7 +418,7 @@ def _prettify(line: str) -> str | None:
|
|
|
391
418
|
kept_txt = f" \u00b7 kept {kept}" if kept != "0" else ""
|
|
392
419
|
return f"Scanning X \u00b7 {proj} \u00b7 \u201c{query}\u201d{kept_txt}"
|
|
393
420
|
# A few recognizable phase markers; otherwise show the trimmed tail.
|
|
394
|
-
if "posting" in low or "posted reply" in low:
|
|
421
|
+
if ("posting" in low and "without posting" not in low and "not posting" not in low) or "posted reply" in low:
|
|
395
422
|
return "Posting reply on X\u2026"
|
|
396
423
|
if "drafting" in low or "draft" in low and "cycle" not in low:
|
|
397
424
|
return "Drafting replies\u2026"
|
|
@@ -517,7 +544,12 @@ def cmd_watch(interval: float = 2.0) -> int:
|
|
|
517
544
|
|
|
518
545
|
threading.Thread(target=_watchdog, daemon=True).start()
|
|
519
546
|
|
|
547
|
+
# Backoff while there is nothing to paint (harness Chrome down, or no http
|
|
548
|
+
# tab): reconnecting every tick spawned a new Playwright node driver each time.
|
|
549
|
+
IDLE_BACKOFF_SEC = 30.0
|
|
520
550
|
last_status = None
|
|
551
|
+
status_ts = int(time.time() * 1000)
|
|
552
|
+
painted = None # (status, page urls) as of the last paint
|
|
521
553
|
h: Harness | None = None
|
|
522
554
|
registered = False
|
|
523
555
|
try:
|
|
@@ -529,21 +561,33 @@ def cmd_watch(interval: float = 2.0) -> int:
|
|
|
529
561
|
status = _current_status()
|
|
530
562
|
except Exception:
|
|
531
563
|
status = "Working\u2026"
|
|
564
|
+
if status != last_status:
|
|
565
|
+
status_ts = int(time.time() * 1000)
|
|
566
|
+
sleep_for = interval
|
|
532
567
|
try:
|
|
533
568
|
if h is None:
|
|
534
569
|
h = Harness().__enter__()
|
|
535
570
|
registered = False
|
|
571
|
+
painted = None
|
|
572
|
+
if not h.connected():
|
|
573
|
+
raise RuntimeError("harness Chrome disconnected")
|
|
536
574
|
if not registered:
|
|
537
575
|
# Re-register init on each (re)connect so fresh tabs inherit it.
|
|
538
576
|
h.register_init(TITLE, REASSURE, status)
|
|
539
577
|
registered = True
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
#
|
|
544
|
-
|
|
578
|
+
pages = h._pages()
|
|
579
|
+
if not pages:
|
|
580
|
+
# No http tab (closed or mid-navigation) is a normal idle state:
|
|
581
|
+
# keep the connection and check back slowly.
|
|
582
|
+
sleep_for = IDLE_BACKOFF_SEC
|
|
583
|
+
else:
|
|
584
|
+
# A navigation replaces the document with the init script's
|
|
585
|
+
# seed text, so repaint on URL changes as well as status changes.
|
|
586
|
+
sig = (status, tuple(sorted(p.url for p in pages)))
|
|
587
|
+
if sig != painted and h.paint(TITLE, REASSURE, status, status_ts) > 0:
|
|
588
|
+
painted = sig
|
|
545
589
|
except Exception:
|
|
546
|
-
# Harness down or
|
|
590
|
+
# Harness down or CDP hiccup; tear down and retry after a backoff.
|
|
547
591
|
if h is not None:
|
|
548
592
|
try:
|
|
549
593
|
h.__exit__(None, None, None)
|
|
@@ -551,10 +595,11 @@ def cmd_watch(interval: float = 2.0) -> int:
|
|
|
551
595
|
pass
|
|
552
596
|
h = None
|
|
553
597
|
registered = False
|
|
598
|
+
sleep_for = IDLE_BACKOFF_SEC
|
|
554
599
|
if status != last_status:
|
|
555
600
|
print(f"[{time.strftime('%H:%M:%S')}] {status}")
|
|
556
601
|
last_status = status
|
|
557
|
-
time.sleep(
|
|
602
|
+
time.sleep(sleep_for)
|
|
558
603
|
except KeyboardInterrupt:
|
|
559
604
|
print("\nstopping watch; clearing overlay")
|
|
560
605
|
finally:
|
|
@@ -29,10 +29,31 @@ set -euo pipefail
|
|
|
29
29
|
# `*social-autoposter` dir that actually carries a manifest.json.
|
|
30
30
|
APP_SUPPORT="$HOME/Library/Application Support"
|
|
31
31
|
EXT_DIR=""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
# LIVE profile first (2026-09-28): on a multi-profile machine (account rotator:
|
|
33
|
+
# Claude, Claude-mediar, Claude-mddy, ...) every profile keeps its OWN copy of
|
|
34
|
+
# the extension, and "newest mtime" is just the profile updated most recently,
|
|
35
|
+
# not the one Claude is running right now. The operator Mac sat on 1.7.7-rc.4
|
|
36
|
+
# in the live Claude-mediar profile for two months while every update landed
|
|
37
|
+
# in Claude-mddy, and this script kept printing "already on latest". Prefer
|
|
38
|
+
# the extension dir under a RUNNING Claude's --user-data-dir (no flag == plain
|
|
39
|
+
# "Claude"); fall back to the newest-mtime scan only when nothing is running.
|
|
40
|
+
# Mirrors mcp/menubar/s4l_menubar.py::_ext_dir; keep the two in sync.
|
|
41
|
+
while IFS= read -r line; do
|
|
42
|
+
case "$line" in *"/Claude.app/Contents/MacOS/Claude"*) ;; *) continue ;; esac
|
|
43
|
+
udd="$(printf '%s' "$line" | sed -n 's/.*--user-data-dir=\(.*\)$/\1/p' | sed 's/ --.*$//')"
|
|
44
|
+
root="${udd:-$APP_SUPPORT/Claude}/Claude Extensions"
|
|
45
|
+
for d in "$root"/*social-autoposter; do
|
|
46
|
+
[ -f "$d/manifest.json" ] || continue
|
|
47
|
+
EXT_DIR="$d"; break
|
|
48
|
+
done
|
|
49
|
+
[ -n "$EXT_DIR" ] && break
|
|
50
|
+
done < <(ps -axo command 2>/dev/null)
|
|
51
|
+
if [ -z "$EXT_DIR" ]; then
|
|
52
|
+
for d in "$APP_SUPPORT"/Claude*/"Claude Extensions"/*social-autoposter; do
|
|
53
|
+
[ -f "$d/manifest.json" ] || continue
|
|
54
|
+
if [ -z "$EXT_DIR" ] || [ "$d" -nt "$EXT_DIR" ]; then EXT_DIR="$d"; fi
|
|
55
|
+
done
|
|
56
|
+
fi
|
|
36
57
|
# Last-resort fallback to the historical path so behavior is unchanged on old boxes.
|
|
37
58
|
[ -n "$EXT_DIR" ] || EXT_DIR="$APP_SUPPORT/Claude/Claude Extensions/local.mcpb.m13v.social-autoposter"
|
|
38
59
|
PY="/usr/bin/python3"
|
|
@@ -89,11 +89,6 @@ def _registry_worker_recs(by_id):
|
|
|
89
89
|
return recs
|
|
90
90
|
return None
|
|
91
91
|
|
|
92
|
-
# A worker task whose lastRunAt is within this many seconds counts as "firing".
|
|
93
|
-
# 7 min tolerates the host's per-task throttle + Claude restart gaps without a
|
|
94
|
-
# false "not scheduled".
|
|
95
|
-
FIRING_WINDOW = 420
|
|
96
|
-
|
|
97
92
|
# --- Stall thresholds (single source of truth) --------------------------------
|
|
98
93
|
# The longest a fully healthy drain can take end to end: a draft job can wait
|
|
99
94
|
# minutes for a worker slot (per-minute scheduler cadence, workers busy on other
|
|
@@ -116,6 +111,15 @@ RUNNING_STALL_SECONDS = HEALTHY_DRAIN_MAX_SECONDS
|
|
|
116
111
|
# is the only timer that spans claim→die→requeue churn loops, which reset the two
|
|
117
112
|
# per-state timers above on every transition while nothing ever completes.
|
|
118
113
|
DRAFT_STUCK_SECONDS = HEALTHY_DRAIN_MAX_SECONDS
|
|
114
|
+
# A worker task whose lastRunAt is within this many seconds counts as "firing".
|
|
115
|
+
# The host scheduler serializes runs of one task (overlap guard), so lastRunAt
|
|
116
|
+
# stays frozen for the whole duration of an in-flight run. A healthy draft run
|
|
117
|
+
# can take up to HEALTHY_DRAIN_MAX_SECONDS, so the window must be at least that
|
|
118
|
+
# long; the old 7-minute window flipped tasks_scheduled to "stalled" mid-run on
|
|
119
|
+
# every 8-15 minute drain (operator Mac, 2026-09-28: checklist showed 8/10 with
|
|
120
|
+
# the worker alive and firing between jobs). A genuinely wedged scheduler still
|
|
121
|
+
# trips once the drain budget lapses.
|
|
122
|
+
FIRING_WINDOW = HEALTHY_DRAIN_MAX_SECONDS
|
|
119
123
|
|
|
120
124
|
# Grace for a JUST-scheduled task that hasn't fired yet. When the user runs
|
|
121
125
|
# "Set up draft schedule", create_scheduled_task registers both worker tasks
|
|
@@ -1258,6 +1258,12 @@ for q in queries:
|
|
|
1258
1258
|
dt = time.time() - t0
|
|
1259
1259
|
print(f' err project={project!r} q={query[:50]!r} in {dt:.1f}s {type(e).__name__}: {e}', flush=True)
|
|
1260
1260
|
PY
|
|
1261
|
+
# Park the X tab now: DRAFT_ONLY and empty-plan cycles exit before Phase 2b,
|
|
1262
|
+
# so twitter_browser.py's at-exit parker never runs and the tab would sit on
|
|
1263
|
+
# a live x.com search (scripts + service worker running) until the next
|
|
1264
|
+
# cycle. robots.txt, never about:blank (focus steal; see browser_lifecycle.py).
|
|
1265
|
+
[ -z "${S4L_NO_TAB_PARK:-}" ] && python3 -c "import sys; sys.path.insert(0, sys.argv[1]); from browser_lifecycle import park_tabs; park_tabs(sys.argv[2], ('x.com', 'twitter.com'), 'https://x.com/robots.txt', 'twitter_scan')" \
|
|
1266
|
+
"$REPO_DIR/scripts" "${TWITTER_CDP_URL:-http://127.0.0.1:9555}" 2>&1 | tee -a "$LOG_FILE" || true
|
|
1261
1267
|
fi
|
|
1262
1268
|
rm -f "$QUERIES_TMP"
|
|
1263
1269
|
|