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
- console.log(` browser-harness clone exists -> ${harnessDir}`);
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
- const install = spawnSync(uvBin, ['tool', 'install', '-e', harnessDir], { stdio: 'inherit' });
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 ...`).
@@ -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
- """Kill the managed Chrome instance, if any."""
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
- try:
269
- os.kill(pid, 15)
270
- time.sleep(0.6)
271
- if _pid_alive(pid):
272
- os.kill(pid, 9)
273
- except OSError as e:
274
- return {"status": "error", "error": str(e), "pid": pid}
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", "pid": pid}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.20",
3
+ "version": "1.6.22",
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"
@@ -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 (md5 low bit) so a re-run of the
79
- # same cycle id lands the same arm. Set LENGTH_AB_ENABLED=0 to disable the
80
- # experiment: every cycle then runs treatment (full length-control behavior).
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
- _len_hash=$(printf '%s' "$BATCH_ID" | md5 2>/dev/null || printf '%s' "$BATCH_ID" | md5sum | awk '{print $1}')
84
- _len_last=$(printf '%s' "$_len_hash" | tail -c 1)
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
- [02468ace]) LENGTH_ARM="treatment" ;;
87
- *) LENGTH_ARM="control" ;;
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
- FRESHNESS_HOURS=6
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 continues to use FRESHNESS_HOURS=6 (the union ceiling) so
128
- # peer cycles don't accidentally expire each other's still-pending rows. Only
129
- # FRESHNESS_HOURS_DISCOVER (Phase 1 prompt + since-rewrite hook) is tightened to
130
- # 1h, which was the winning D setting.
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