social-autoposter 1.6.21 → 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.
@@ -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.21",
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,