social-autoposter 1.6.20 → 1.6.22
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/bin/cli.js
CHANGED
|
@@ -453,15 +453,38 @@ function installBrowserHarness() {
|
|
|
453
453
|
console.warn(' WARNING: git clone failed; twitter-harness will not work until you clone manually.');
|
|
454
454
|
}
|
|
455
455
|
} else {
|
|
456
|
-
|
|
456
|
+
// Refresh the existing clone instead of silently reusing it. server.py
|
|
457
|
+
// invokes `browser-harness -c <script>`; a stale checkout that predates the
|
|
458
|
+
// `-c` interface (or otherwise drifted from upstream) makes every bh_run
|
|
459
|
+
// return the CLI usage string while looking "installed". fetch+reset --hard
|
|
460
|
+
// to current upstream so the installed CLI always matches the shipped
|
|
461
|
+
// server.py contract.
|
|
462
|
+
console.log(` browser-harness clone exists -> ${harnessDir}; updating to latest...`);
|
|
463
|
+
const fetch = spawnSync('git', ['-C', harnessDir, 'fetch', '--depth', '1', 'origin', 'HEAD'], { stdio: 'inherit' });
|
|
464
|
+
if (fetch.status === 0) {
|
|
465
|
+
const reset = spawnSync('git', ['-C', harnessDir, 'reset', '--hard', 'FETCH_HEAD'], { stdio: 'inherit' });
|
|
466
|
+
if (reset.status !== 0) {
|
|
467
|
+
console.warn(' WARNING: could not reset browser-harness clone to latest; using existing checkout.');
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
console.warn(' WARNING: could not fetch browser-harness updates; using existing checkout.');
|
|
471
|
+
}
|
|
457
472
|
}
|
|
458
473
|
|
|
459
474
|
if (uvBin && fs.existsSync(harnessDir)) {
|
|
460
475
|
console.log(' installing browser-harness CLI via uv tool...');
|
|
461
|
-
|
|
476
|
+
// --force so a refreshed source / changed entry point is reinstalled even
|
|
477
|
+
// when the tool is already present (a plain re-install is otherwise a no-op).
|
|
478
|
+
const install = spawnSync(uvBin, ['tool', 'install', '--force', '-e', harnessDir], { stdio: 'inherit' });
|
|
462
479
|
if (install.status !== 0) {
|
|
463
480
|
console.warn(' WARNING: `uv tool install -e .` failed; check the output above.');
|
|
464
481
|
}
|
|
482
|
+
// The harness daemon caches imported code in a long-running process; drop it
|
|
483
|
+
// so the next bh_run loads the freshly-installed CLI instead of stale code.
|
|
484
|
+
const harnessBin = path.join(HOME, '.local', 'bin', 'browser-harness');
|
|
485
|
+
if (fs.existsSync(harnessBin)) {
|
|
486
|
+
spawnSync(harnessBin, ['--reload'], { stdio: 'inherit' });
|
|
487
|
+
}
|
|
465
488
|
}
|
|
466
489
|
|
|
467
490
|
// Step 4: ensure mcp Python package available (server.py uses `from mcp.server.fastmcp ...`).
|
|
Binary file
|
|
@@ -261,17 +261,62 @@ def ensure_chrome() -> dict:
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
def _port_owner_pids() -> list[int]:
|
|
265
|
+
"""PIDs LISTENing on our debug PORT, via lsof. Lets stop_chrome reap a Chrome
|
|
266
|
+
that another launcher (e.g. setup_twitter_auth.py's connect_x) started without
|
|
267
|
+
writing PID_FILE, instead of stranding an un-reapable orphan that makes
|
|
268
|
+
bh_start keep reporting 'already_running'. Returns [] if lsof is unavailable."""
|
|
269
|
+
try:
|
|
270
|
+
out = subprocess.run(
|
|
271
|
+
["lsof", "-ti", f"tcp:{PORT}", "-sTCP:LISTEN"],
|
|
272
|
+
capture_output=True, text=True, timeout=5,
|
|
273
|
+
)
|
|
274
|
+
except (OSError, subprocess.SubprocessError):
|
|
275
|
+
return []
|
|
276
|
+
pids = []
|
|
277
|
+
for tok in (out.stdout or "").split():
|
|
278
|
+
try:
|
|
279
|
+
pids.append(int(tok))
|
|
280
|
+
except ValueError:
|
|
281
|
+
pass
|
|
282
|
+
return pids
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _terminate(pid: int, grace: float = 5.0) -> None:
|
|
286
|
+
"""SIGTERM, then SIGKILL only if still alive after `grace` seconds. The wait
|
|
287
|
+
gives Chrome time to flush its in-memory cookie store to the on-disk profile,
|
|
288
|
+
so a stop->start restart preserves the X session instead of coming back
|
|
289
|
+
logged out (the failure the setup agent hit after a hard kill)."""
|
|
290
|
+
try:
|
|
291
|
+
os.kill(pid, 15)
|
|
292
|
+
except OSError:
|
|
293
|
+
return
|
|
294
|
+
deadline = time.time() + grace
|
|
295
|
+
while time.time() < deadline:
|
|
296
|
+
if not _pid_alive(pid):
|
|
297
|
+
return
|
|
298
|
+
time.sleep(0.2)
|
|
299
|
+
try:
|
|
300
|
+
os.kill(pid, 9)
|
|
301
|
+
except OSError:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
|
|
264
305
|
def stop_chrome() -> dict:
|
|
265
|
-
"""
|
|
306
|
+
"""Gracefully stop the managed Chrome — the tracked process AND any orphan
|
|
307
|
+
still LISTENing on the debug port — so connect_x-launched Chromes can't
|
|
308
|
+
strand the port. SIGTERM-with-grace lets Chrome persist cookies to disk."""
|
|
309
|
+
targets: list[int] = []
|
|
266
310
|
pid = _read_pid()
|
|
267
311
|
if pid and _pid_alive(pid):
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
312
|
+
targets.append(pid)
|
|
313
|
+
for owner in _port_owner_pids():
|
|
314
|
+
if owner not in targets and _pid_alive(owner):
|
|
315
|
+
targets.append(owner)
|
|
316
|
+
|
|
317
|
+
for t in targets:
|
|
318
|
+
_terminate(t)
|
|
319
|
+
|
|
275
320
|
try:
|
|
276
321
|
PID_FILE.unlink()
|
|
277
322
|
except FileNotFoundError:
|
|
@@ -281,7 +326,7 @@ def stop_chrome() -> dict:
|
|
|
281
326
|
os.unlink(stale)
|
|
282
327
|
except FileNotFoundError:
|
|
283
328
|
pass
|
|
284
|
-
return {"status": "stopped", "
|
|
329
|
+
return {"status": "stopped", "reaped": targets, "tracked_pid": pid}
|
|
285
330
|
|
|
286
331
|
|
|
287
332
|
# --- browser-harness exec wrapper ---
|
|
@@ -375,12 +420,16 @@ def bh_run(script: str, timeout: int = EXEC_TIMEOUT_SEC) -> str:
|
|
|
375
420
|
def bh_status() -> str:
|
|
376
421
|
"""Report whether the managed Chrome is alive and where it lives."""
|
|
377
422
|
pid = _read_pid()
|
|
423
|
+
owners = _port_owner_pids()
|
|
378
424
|
return json.dumps(
|
|
379
425
|
{
|
|
380
426
|
"cdp_url": CDP_URL,
|
|
381
427
|
"cdp_alive": _cdp_alive(),
|
|
382
428
|
"chrome_pid": pid,
|
|
383
|
-
"chrome_alive": pid is not None and _pid_alive(pid),
|
|
429
|
+
"chrome_alive": (pid is not None and _pid_alive(pid)) or _cdp_alive(),
|
|
430
|
+
# Untracked Chromes (e.g. launched by connect_x) show up here even
|
|
431
|
+
# when chrome_pid is null — that's the orphan that bh_stop now reaps.
|
|
432
|
+
"port_owner_pids": owners,
|
|
384
433
|
"profile_dir": str(PROFILE_DIR),
|
|
385
434
|
"log_file": str(LOG_FILE),
|
|
386
435
|
"harness_bin": BROWSER_HARNESS_BIN,
|
|
@@ -400,10 +449,26 @@ def bh_start() -> str:
|
|
|
400
449
|
|
|
401
450
|
@mcp.tool()
|
|
402
451
|
def bh_stop() -> str:
|
|
403
|
-
"""Kill the managed Chrome instance. Cookies/profile data persist on disk.
|
|
452
|
+
"""Kill the managed Chrome instance. Cookies/profile data persist on disk.
|
|
453
|
+
|
|
454
|
+
Reaps both the tracked process and any orphan still holding the debug port,
|
|
455
|
+
using a graceful SIGTERM-with-grace so cookies flush to disk first."""
|
|
404
456
|
return json.dumps(stop_chrome(), indent=2)
|
|
405
457
|
|
|
406
458
|
|
|
459
|
+
@mcp.tool()
|
|
460
|
+
def bh_restart() -> str:
|
|
461
|
+
"""Flush + restart the managed Chrome in one step. Gracefully stops it (so
|
|
462
|
+
Chrome persists the cookie store to disk and any port-orphan is reaped), then
|
|
463
|
+
starts a fresh instance that loads the just-flushed session from disk. Use
|
|
464
|
+
this instead of killing Chrome by hand — a hard kill drops the in-memory X
|
|
465
|
+
session before it is written, which is what forces a re-login."""
|
|
466
|
+
stopped = stop_chrome()
|
|
467
|
+
time.sleep(0.5)
|
|
468
|
+
started = ensure_chrome()
|
|
469
|
+
return json.dumps({"status": "restarted", "stopped": stopped, "started": started}, indent=2)
|
|
470
|
+
|
|
471
|
+
|
|
407
472
|
@mcp.tool()
|
|
408
473
|
def bh_seed_cookies(source: str = "chrome:Default", domains: str | None = None) -> str:
|
|
409
474
|
"""Import cookies from a local browser profile into the managed Chrome.
|
package/package.json
CHANGED
|
@@ -63,12 +63,27 @@ except ImportError:
|
|
|
63
63
|
)
|
|
64
64
|
sys.exit(0)
|
|
65
65
|
|
|
66
|
+
# Optional server-side session-cookie store (best-effort). Lets connect_x persist
|
|
67
|
+
# the validated X cookies so restore_twitter_session.py can auto-re-inject them
|
|
68
|
+
# after any logout. Guarded so a missing dep or offline API never breaks setup.
|
|
69
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
70
|
+
try:
|
|
71
|
+
from http_api import api_post # noqa: E402
|
|
72
|
+
from twitter_account import resolve_handle # noqa: E402
|
|
73
|
+
except Exception:
|
|
74
|
+
api_post = None
|
|
75
|
+
resolve_handle = None
|
|
76
|
+
|
|
66
77
|
# --- Config -----------------------------------------------------------------
|
|
67
78
|
|
|
68
79
|
# Same managed Chrome the twitter-harness pipeline uses (skill/lib/twitter-backend.sh).
|
|
69
80
|
CDP = os.environ.get("SAPS_TWITTER_CDP_URL", os.environ.get("TWITTER_CDP_URL", "http://127.0.0.1:9555")).rstrip("/")
|
|
70
81
|
PORT = int(CDP.rsplit(":", 1)[-1]) if CDP.rsplit(":", 1)[-1].isdigit() else 9555
|
|
71
82
|
PROFILE_DIR = Path.home() / ".claude" / "browser-profiles" / "browser-harness"
|
|
83
|
+
# Same PID file server.py (the twitter-harness MCP) writes, so a Chrome launched
|
|
84
|
+
# here is tracked and reapable by bh_stop instead of becoming an orphan that
|
|
85
|
+
# strands the debug port.
|
|
86
|
+
PID_FILE = Path.home() / ".claude" / "browser-profiles" / "browser-harness.chrome.pid"
|
|
72
87
|
|
|
73
88
|
# Browsers ai_browser_profile.cookies can read from, in auto-detect priority.
|
|
74
89
|
AUTO_SOURCES = ["chrome:Default", "arc:Default", "brave:Default", "edge:Default"]
|
|
@@ -163,7 +178,11 @@ def _launch_chrome() -> bool:
|
|
|
163
178
|
cmd += ["--window-position=80,80", "--window-size=1100,900"]
|
|
164
179
|
cmd.append("about:blank")
|
|
165
180
|
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
166
|
-
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
181
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
182
|
+
try:
|
|
183
|
+
PID_FILE.write_text(str(proc.pid))
|
|
184
|
+
except OSError:
|
|
185
|
+
pass
|
|
167
186
|
for _ in range(15):
|
|
168
187
|
if _cdp_alive():
|
|
169
188
|
return True
|
|
@@ -258,6 +277,50 @@ def _has_session_quick() -> bool:
|
|
|
258
277
|
pass
|
|
259
278
|
|
|
260
279
|
|
|
280
|
+
def _save_session_to_store() -> None:
|
|
281
|
+
"""Best-effort: persist the live x.com cookies to the server-side session
|
|
282
|
+
store (social_accounts.session_cookies, via POST /api/v1/twitter/session-cookies)
|
|
283
|
+
so restore_twitter_session.py can auto-re-inject them on the next preflight
|
|
284
|
+
after ANY logout — hard kill, crash, or AppMaker VM reseed. Non-fatal: the
|
|
285
|
+
local session is already valid; this only enables future auto-recovery.
|
|
286
|
+
Without it the restore rail has nothing to read and every logout needs a
|
|
287
|
+
manual connect_x."""
|
|
288
|
+
if api_post is None or resolve_handle is None:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
handle = resolve_handle()
|
|
292
|
+
except Exception:
|
|
293
|
+
handle = None
|
|
294
|
+
if not handle:
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
ws, send = _attach()
|
|
298
|
+
except Exception:
|
|
299
|
+
return
|
|
300
|
+
try:
|
|
301
|
+
send("Network.enable")
|
|
302
|
+
r = send("Network.getAllCookies")
|
|
303
|
+
cks = r.get("result", {}).get("cookies", []) or []
|
|
304
|
+
wanted = tuple(d.strip() for d in DOMAINS.split(",") if d.strip())
|
|
305
|
+
cookies = [c for c in cks if any(w in (c.get("domain") or "") for w in wanted)]
|
|
306
|
+
if not cookies:
|
|
307
|
+
return
|
|
308
|
+
api_post("/api/v1/twitter/session-cookies", {"handle": handle, "cookies": cookies})
|
|
309
|
+
print(f"setup_twitter_auth: saved {len(cookies)} session cookies for @{handle} "
|
|
310
|
+
"(auto-restore enabled)", file=sys.stderr)
|
|
311
|
+
# api_post raises SystemExit (BaseException, NOT Exception) on a 4xx/5xx —
|
|
312
|
+
# e.g. "no social_accounts row" on a persistent machine that never registered
|
|
313
|
+
# this handle. The save is best-effort and must never abort connect_x, so
|
|
314
|
+
# catch SystemExit too.
|
|
315
|
+
except (Exception, SystemExit) as e:
|
|
316
|
+
print(f"setup_twitter_auth: session-store save skipped ({e})", file=sys.stderr)
|
|
317
|
+
finally:
|
|
318
|
+
try:
|
|
319
|
+
ws.close()
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
|
|
261
324
|
def _show_window_and_open_login() -> bool:
|
|
262
325
|
"""Make the managed Chrome window VISIBLE + focused and land it on the X login
|
|
263
326
|
page, so the user can sign in by hand (the manual-login fallback).
|
|
@@ -387,6 +450,7 @@ def cmd_connect(args) -> dict:
|
|
|
387
450
|
# 1. Already logged in? Nothing to import.
|
|
388
451
|
try:
|
|
389
452
|
if _is_session_valid():
|
|
453
|
+
_save_session_to_store()
|
|
390
454
|
return {
|
|
391
455
|
"ok": True,
|
|
392
456
|
"connected": True,
|
|
@@ -410,6 +474,7 @@ def cmd_connect(args) -> dict:
|
|
|
410
474
|
# 3. Re-validate after this source.
|
|
411
475
|
try:
|
|
412
476
|
if _is_session_valid():
|
|
477
|
+
_save_session_to_store()
|
|
413
478
|
return {
|
|
414
479
|
"ok": True,
|
|
415
480
|
"connected": True,
|
|
@@ -75,22 +75,34 @@ export SA_CYCLE_ID="$BATCH_ID"
|
|
|
75
75
|
# control = generic "keep it tight" guidance, no per-style number, no gate
|
|
76
76
|
# (reproduces pre-length-project length behavior WITHOUT reverting
|
|
77
77
|
# the bug fixes shipped alongside the length project)
|
|
78
|
-
# Assignment is deterministic from BATCH_ID
|
|
79
|
-
# same cycle id lands the same arm. Set LENGTH_AB_ENABLED=0
|
|
80
|
-
# experiment: every cycle then runs treatment (full length
|
|
78
|
+
# Assignment is deterministic from BATCH_ID's trailing timestamp digit (parity)
|
|
79
|
+
# so a re-run of the same cycle id lands the same arm. Set LENGTH_AB_ENABLED=0
|
|
80
|
+
# to disable the experiment: every cycle then runs treatment (full length
|
|
81
|
+
# control behavior).
|
|
82
|
+
#
|
|
83
|
+
# DO NOT switch this back to md5/md5sum. Those binaries live in /sbin on macOS
|
|
84
|
+
# (and /usr/bin/md5sum-only on Linux), and /sbin is NOT on the launchd PATH
|
|
85
|
+
# (node/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin). On 2026-06-02 an
|
|
86
|
+
# md5-based split silently produced an EMPTY hash under cron, fell through the
|
|
87
|
+
# case to control, and stamped 173/173 control in 12h (a dead experiment that
|
|
88
|
+
# looked alive). tr + tail live in /usr/bin (on the launchd PATH, and present
|
|
89
|
+
# on both macOS and the Linux VMs), so the digit split is PATH- and platform-
|
|
90
|
+
# safe. Empty/garbage fails OPEN to treatment (full length behavior), never
|
|
91
|
+
# silently to one arm.
|
|
81
92
|
LENGTH_AB_ENABLED="${LENGTH_AB_ENABLED:-1}"
|
|
82
93
|
if [ "$LENGTH_AB_ENABLED" = "1" ]; then
|
|
83
|
-
|
|
84
|
-
_len_last=$(printf '%s' "$
|
|
94
|
+
_len_digits=$(printf '%s' "$BATCH_ID" | tr -cd '0-9')
|
|
95
|
+
_len_last=$(printf '%s' "$_len_digits" | tail -c 1)
|
|
85
96
|
case "$_len_last" in
|
|
86
|
-
[
|
|
87
|
-
|
|
97
|
+
[13579]) LENGTH_ARM="treatment" ;;
|
|
98
|
+
[02468]) LENGTH_ARM="control" ;;
|
|
99
|
+
*) LENGTH_ARM="treatment" ;;
|
|
88
100
|
esac
|
|
89
101
|
else
|
|
90
102
|
LENGTH_ARM="treatment"
|
|
103
|
+
_len_last="off"
|
|
91
104
|
fi
|
|
92
105
|
export LENGTH_ARM
|
|
93
|
-
echo "[length-ab] LENGTH_AB_ENABLED=$LENGTH_AB_ENABLED arm=$LENGTH_ARM batch=$BATCH_ID" >&2
|
|
94
106
|
|
|
95
107
|
LOG_FILE="$LOG_DIR/twitter-cycle-$(date +%Y-%m-%d_%H%M%S).log"
|
|
96
108
|
RAW_FILE="/tmp/twitter_cycle_raw_$(date +%s).json"
|
|
@@ -112,7 +124,12 @@ TW_ENGINE_PREFIX=""
|
|
|
112
124
|
# Tweets older than this are no longer worth replying to. Pending rows older
|
|
113
125
|
# than this are hard-expired by Phase 0; younger pending rows are salvaged
|
|
114
126
|
# from prior cycles into this batch.
|
|
115
|
-
|
|
127
|
+
# 2026-06-01: tightened 6h -> 2h. The pending pool had bloated to 636 rows,
|
|
128
|
+
# 523 of them >6h old (median virality 0.44, far below the ~5.8 posted median),
|
|
129
|
+
# because the salvage loop kept re-carrying stale low-virality junk. A 2h
|
|
130
|
+
# ceiling drops that carry runway so aged-out junk expires instead of riding
|
|
131
|
+
# ~80 cycles. Discovery is already capped at 1h (FRESHNESS_HOURS_DISCOVER).
|
|
132
|
+
FRESHNESS_HOURS=2
|
|
116
133
|
|
|
117
134
|
# ----------------------------------------------------------------------------
|
|
118
135
|
# EXPERIMENT CONCLUDED 2026-05-31: variant D won the ripen+freshness A/B/C/D
|
|
@@ -124,10 +141,10 @@ FRESHNESS_HOURS=6
|
|
|
124
141
|
# permanent, hardcoded behavior. The cycle_variant column is still stamped 'D'
|
|
125
142
|
# below so historical analytics keep a consistent label.
|
|
126
143
|
#
|
|
127
|
-
# Phase 0 hard-expire
|
|
128
|
-
# peer cycles don't accidentally expire each
|
|
129
|
-
# FRESHNESS_HOURS_DISCOVER (Phase 1 prompt +
|
|
130
|
-
# 1h,
|
|
144
|
+
# Phase 0 hard-expire uses FRESHNESS_HOURS (the union ceiling, tightened to 2h
|
|
145
|
+
# on 2026-06-01, see above) so peer cycles don't accidentally expire each
|
|
146
|
+
# other's still-pending rows. FRESHNESS_HOURS_DISCOVER (Phase 1 prompt +
|
|
147
|
+
# since-rewrite hook) stays tightened to 1h, the winning D setting.
|
|
131
148
|
TWITTER_CYCLE_VARIANT=D
|
|
132
149
|
FRESHNESS_HOURS_DISCOVER=1
|
|
133
150
|
export TWITTER_CYCLE_VARIANT FRESHNESS_HOURS_DISCOVER
|
|
@@ -150,6 +167,7 @@ log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
|
|
150
167
|
|
|
151
168
|
log "=== Twitter Cycle (batch=$BATCH_ID): $(date) ==="
|
|
152
169
|
log "Logic=D (no-ripen + 1h freshness + 2k_view_cap; experiment concluded 2026-05-31); discover_freshness=${FRESHNESS_HOURS_DISCOVER}h"
|
|
170
|
+
log "[length-ab] enabled=$LENGTH_AB_ENABLED arm=$LENGTH_ARM (batch=$BATCH_ID, last_digit=${_len_last:-none})"
|
|
153
171
|
|
|
154
172
|
# --- Preflight (added 2026-05-02) -----------------------------------------
|
|
155
173
|
# Three early-exit gates BEFORE we open the DB, set up traps, or touch the
|