@m13v/s4l 1.6.197-rc.10
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/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""reddit_browser_lock.py — explicit per-post browser lock for Reddit.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
link-edit-reddit.sh used to acquire the bash-level reddit-browser lock for
|
|
7
|
+
its entire claude session (~90 min). During that window the orchestrator
|
|
8
|
+
was 99% in SEO page-gen / DB / file-IO time, NOT actually using the reddit
|
|
9
|
+
browser. Other reddit pipelines (engage-reddit, dm-replies-reddit,
|
|
10
|
+
post-reddit) sat blocked the whole time.
|
|
11
|
+
|
|
12
|
+
This helper lets the claude orchestrator acquire/release the reddit-browser
|
|
13
|
+
lock per post, so the lock is only held during the actual `mcp__reddit-agent__*`
|
|
14
|
+
browser ops (typically 15-60s per comment edit) instead of the full run.
|
|
15
|
+
|
|
16
|
+
Interop with skill/lock.sh
|
|
17
|
+
--------------------------
|
|
18
|
+
We share the exact lock-dir format used by skill/lock.sh:
|
|
19
|
+
|
|
20
|
+
/tmp/social-autoposter-<name>.lock/ (the lock; mkdir-atomic)
|
|
21
|
+
pid (one line: owner PID)
|
|
22
|
+
/tmp/social-autoposter-<name>.lock.queue/ (FIFO ticket queue)
|
|
23
|
+
<ns_timestamp>-<pid> (one ticket per waiter)
|
|
24
|
+
|
|
25
|
+
Bash and Python helpers can BOTH compete for the same lock safely. Stale
|
|
26
|
+
detection mirrors lock.sh: missing pid file, dead holder PID, or lock-dir
|
|
27
|
+
age > 3h all trigger steal.
|
|
28
|
+
|
|
29
|
+
Owner PID
|
|
30
|
+
---------
|
|
31
|
+
The lock's owner PID is NOT this short-lived python process — that would
|
|
32
|
+
be detected as dead the moment we exit. Instead, we walk up the process
|
|
33
|
+
tree from os.getppid() looking for the long-lived link-edit-reddit.sh
|
|
34
|
+
(or any claude --session-id ancestor) and use THAT pid. If none is found,
|
|
35
|
+
we fall back to os.getppid() (typically the bash subprocess from claude's
|
|
36
|
+
Bash tool, which lives at least until the tool call returns).
|
|
37
|
+
|
|
38
|
+
Lease/TTL semantics (added 2026-05-08)
|
|
39
|
+
--------------------------------------
|
|
40
|
+
On acquire we also write `expires_at` (a Unix timestamp) inside the lock dir.
|
|
41
|
+
The lock is considered STALE (and stealable by a peer) once `now() > expires_at`.
|
|
42
|
+
|
|
43
|
+
The MCP browser wrapper (`scripts/mcp_lock_proxy.py`) bumps `expires_at` on every
|
|
44
|
+
`tools/call` request that crosses it, and again on every response, plus a periodic
|
|
45
|
+
30s pulse while a request is in flight. So as long as actual reddit-agent browser
|
|
46
|
+
work is happening, the lease keeps renewing. The moment the holder stops calling
|
|
47
|
+
the browser (page-gen, sleeps, DB writes, agent crashes), no more bumps fire and
|
|
48
|
+
the lease auto-expires within `LEASE_TTL_SECONDS` seconds (default 90). Peers
|
|
49
|
+
in the queue then see the lock as stale and steal it.
|
|
50
|
+
|
|
51
|
+
This eliminates orphaned-lock outages without needing the agent to remember to
|
|
52
|
+
call `release` in every code path. `release` still works (and is preferred when
|
|
53
|
+
the agent knows it's done early), but is no longer load-bearing for correctness.
|
|
54
|
+
|
|
55
|
+
CLI
|
|
56
|
+
---
|
|
57
|
+
python3 reddit_browser_lock.py acquire [--name reddit-browser] [--timeout 600] [--ttl 90]
|
|
58
|
+
python3 reddit_browser_lock.py release [--name reddit-browser]
|
|
59
|
+
python3 reddit_browser_lock.py status [--name reddit-browser]
|
|
60
|
+
python3 reddit_browser_lock.py heartbeat [--name reddit-browser] [--ttl 90]
|
|
61
|
+
|
|
62
|
+
Acquire prints a single line:
|
|
63
|
+
OK owner_pid=<N> waited=<sec> ttl=<sec> (success)
|
|
64
|
+
BUSY holder_pid=<N> age=<sec> (timed out)
|
|
65
|
+
ERROR <reason> (unexpected)
|
|
66
|
+
|
|
67
|
+
Release prints:
|
|
68
|
+
OK
|
|
69
|
+
NOT_HELD (lock dir missing)
|
|
70
|
+
HELD_BY_OTHER holder_pid=<N> (don't release — different owner)
|
|
71
|
+
ERROR <reason>
|
|
72
|
+
|
|
73
|
+
Heartbeat prints:
|
|
74
|
+
OK expires_at=<unix_ts> (lease extended)
|
|
75
|
+
NOT_HELD (no lock dir; nothing to extend)
|
|
76
|
+
HELD_BY_OTHER holder_pid=<N> (we're not the owner; refused to bump)
|
|
77
|
+
ERROR <reason>
|
|
78
|
+
|
|
79
|
+
Status prints JSON:
|
|
80
|
+
{"name":"...", "held":bool, "holder_pid":N|null, "age_sec":N|null,
|
|
81
|
+
"expires_at":F|null, "ttl_remaining_sec":F|null, "expired":bool, "queue":[..]}
|
|
82
|
+
|
|
83
|
+
Exit codes
|
|
84
|
+
----------
|
|
85
|
+
0 — success / lock state read cleanly
|
|
86
|
+
1 — timeout / busy / not-held / refused-foreign-release
|
|
87
|
+
2 — usage / argument error
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
from __future__ import annotations
|
|
91
|
+
|
|
92
|
+
import argparse
|
|
93
|
+
import errno
|
|
94
|
+
import json
|
|
95
|
+
import os
|
|
96
|
+
import shutil
|
|
97
|
+
import subprocess
|
|
98
|
+
import sys
|
|
99
|
+
import time
|
|
100
|
+
from pathlib import Path
|
|
101
|
+
|
|
102
|
+
LOCK_ROOT = "/tmp"
|
|
103
|
+
DEFAULT_NAME = "reddit-browser"
|
|
104
|
+
DEFAULT_ACQUIRE_TIMEOUT = 600 # 10 min — generous for a per-post browser slot
|
|
105
|
+
POLL_INTERVAL = 2.0
|
|
106
|
+
STALE_LOCK_AGE = 10800 # 3h, matches lock.sh safety net (final backstop)
|
|
107
|
+
|
|
108
|
+
# Lease/TTL: how long the lock stays valid without a heartbeat. The MCP
|
|
109
|
+
# wrapper auto-heartbeats during browser activity, so a holder that stops
|
|
110
|
+
# making MCP browser calls will see its lease expire after this many seconds
|
|
111
|
+
# of idleness, allowing peers to steal the lock.
|
|
112
|
+
#
|
|
113
|
+
# Sized from real reddit-agent MCP call distribution (n=2390, 14 days):
|
|
114
|
+
# p99 = 30s, max-legit = 5.6 min (one outlier on browser_close).
|
|
115
|
+
# 90s = 15x p99, leaves comfortable headroom inside the wrapper's 30s
|
|
116
|
+
# periodic pulse for in-flight calls. For the rare 5+ min outlier the
|
|
117
|
+
# pulse keeps renewing, so the lease never accidentally expires under
|
|
118
|
+
# real activity.
|
|
119
|
+
DEFAULT_LEASE_TTL_SECONDS = 90
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def lock_paths(name: str) -> tuple[Path, Path, Path]:
|
|
123
|
+
base = Path(LOCK_ROOT) / f"social-autoposter-{name}.lock"
|
|
124
|
+
return base, base / "pid", Path(f"{base}.queue")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def expires_file_path(lock_dir: Path) -> Path:
|
|
128
|
+
return lock_dir / "expires_at"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_expires_at(lock_dir: Path) -> float | None:
|
|
132
|
+
f = expires_file_path(lock_dir)
|
|
133
|
+
if not f.is_file():
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
return float(f.read_text().strip())
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_expires_at(lock_dir: Path, ts: float) -> bool:
|
|
142
|
+
"""Write `expires_at` atomically. Returns True on success.
|
|
143
|
+
|
|
144
|
+
Uses write-then-rename to keep readers from seeing a half-written value.
|
|
145
|
+
Safe no-op if `lock_dir` was removed mid-write (e.g. lock got released
|
|
146
|
+
or stolen between the existence check and the write).
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
if not lock_dir.is_dir():
|
|
150
|
+
return False
|
|
151
|
+
tmp = lock_dir / f"expires_at.tmp.{os.getpid()}"
|
|
152
|
+
tmp.write_text(f"{ts:.3f}\n")
|
|
153
|
+
tmp.replace(expires_file_path(lock_dir))
|
|
154
|
+
return True
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
return False
|
|
157
|
+
except Exception:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def pid_alive(pid: int) -> bool:
|
|
162
|
+
if pid <= 0:
|
|
163
|
+
return False
|
|
164
|
+
try:
|
|
165
|
+
os.kill(pid, 0)
|
|
166
|
+
return True
|
|
167
|
+
except ProcessLookupError:
|
|
168
|
+
return False
|
|
169
|
+
except PermissionError:
|
|
170
|
+
# Process exists, we just can't signal it
|
|
171
|
+
return True
|
|
172
|
+
except OSError as e:
|
|
173
|
+
return e.errno != errno.ESRCH
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def ps_command(pid: int) -> str:
|
|
177
|
+
"""Return the full command line for `pid`, or '' if not found."""
|
|
178
|
+
try:
|
|
179
|
+
r = subprocess.run(
|
|
180
|
+
["ps", "-o", "command=", "-p", str(pid)],
|
|
181
|
+
capture_output=True, text=True, timeout=2,
|
|
182
|
+
)
|
|
183
|
+
return r.stdout.strip()
|
|
184
|
+
except Exception:
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def ps_ppid(pid: int) -> int:
|
|
189
|
+
try:
|
|
190
|
+
r = subprocess.run(
|
|
191
|
+
["ps", "-o", "ppid=", "-p", str(pid)],
|
|
192
|
+
capture_output=True, text=True, timeout=2,
|
|
193
|
+
)
|
|
194
|
+
return int(r.stdout.strip() or 0)
|
|
195
|
+
except Exception:
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def find_owner_pid() -> int:
|
|
200
|
+
"""Walk up the process tree to find a long-lived owner.
|
|
201
|
+
|
|
202
|
+
Looks for: link-edit-reddit.sh, run-reddit-search.sh, engage-reddit.sh,
|
|
203
|
+
or any `claude --session-id` ancestor. Returns the FIRST match. Falls
|
|
204
|
+
back to os.getppid() if none found within depth 12.
|
|
205
|
+
"""
|
|
206
|
+
pid = os.getppid()
|
|
207
|
+
for _ in range(12):
|
|
208
|
+
if pid <= 1:
|
|
209
|
+
break
|
|
210
|
+
cmd = ps_command(pid)
|
|
211
|
+
if not cmd:
|
|
212
|
+
break
|
|
213
|
+
if (
|
|
214
|
+
"link-edit-reddit.sh" in cmd
|
|
215
|
+
or "run-reddit-search.sh" in cmd
|
|
216
|
+
or "engage-reddit.sh" in cmd
|
|
217
|
+
or "engage-dm-replies-reddit.sh" in cmd
|
|
218
|
+
or "scan-reddit-replies" in cmd
|
|
219
|
+
or ("claude" in cmd and "--session-id" in cmd)
|
|
220
|
+
):
|
|
221
|
+
return pid
|
|
222
|
+
pid = ps_ppid(pid)
|
|
223
|
+
return os.getppid()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def gc_stale_tickets(queue_dir: Path) -> None:
|
|
227
|
+
if not queue_dir.is_dir():
|
|
228
|
+
return
|
|
229
|
+
for ticket in queue_dir.iterdir():
|
|
230
|
+
try:
|
|
231
|
+
tpid = int(ticket.read_text().strip() or "0")
|
|
232
|
+
except Exception:
|
|
233
|
+
continue
|
|
234
|
+
if not pid_alive(tpid):
|
|
235
|
+
try:
|
|
236
|
+
ticket.unlink()
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def lock_is_stale(lock_dir: Path, pid_file: Path) -> tuple[bool, str]:
|
|
242
|
+
"""Return (is_stale, reason) for a lock dir we found existing.
|
|
243
|
+
|
|
244
|
+
Checked in order:
|
|
245
|
+
1. pid file missing / unparseable / zero
|
|
246
|
+
2. holder PID is dead
|
|
247
|
+
3. lease TTL expired (`now() > expires_at`) — primary stale signal
|
|
248
|
+
4. lock dir mtime older than STALE_LOCK_AGE (final backstop, only
|
|
249
|
+
matters for legacy locks that never wrote `expires_at`)
|
|
250
|
+
"""
|
|
251
|
+
if not pid_file.is_file():
|
|
252
|
+
return True, "no_pid_file"
|
|
253
|
+
try:
|
|
254
|
+
holder = int(pid_file.read_text().strip() or "0")
|
|
255
|
+
except Exception:
|
|
256
|
+
return True, "unparseable_pid"
|
|
257
|
+
if holder <= 0:
|
|
258
|
+
return True, "zero_pid"
|
|
259
|
+
if not pid_alive(holder):
|
|
260
|
+
return True, f"dead_holder_{holder}"
|
|
261
|
+
# Primary staleness signal: TTL expired (no heartbeat in TTL window).
|
|
262
|
+
expires_at = read_expires_at(lock_dir)
|
|
263
|
+
if expires_at is not None:
|
|
264
|
+
idle = time.time() - expires_at
|
|
265
|
+
if idle > 0:
|
|
266
|
+
return True, f"ttl_expired_idle_{int(idle)}s_holder_{holder}"
|
|
267
|
+
try:
|
|
268
|
+
age = time.time() - lock_dir.stat().st_mtime
|
|
269
|
+
if age > STALE_LOCK_AGE:
|
|
270
|
+
return True, f"age_{int(age)}s_>_{STALE_LOCK_AGE}s"
|
|
271
|
+
except FileNotFoundError:
|
|
272
|
+
return True, "lock_dir_vanished"
|
|
273
|
+
return False, ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def remove_lock(lock_dir: Path) -> None:
|
|
277
|
+
try:
|
|
278
|
+
shutil.rmtree(lock_dir, ignore_errors=True)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def sweep_orphan_browser_processes(name: str) -> None:
|
|
284
|
+
"""Kill orphan Chrome / playwright-mcp processes reparented to PID 1.
|
|
285
|
+
|
|
286
|
+
Ported from skill/lock.sh:175-198 on 2026-05-10 as part of the migration
|
|
287
|
+
that consolidates reddit-browser locking onto a single TTL-aware system.
|
|
288
|
+
|
|
289
|
+
A prior holder may have exited without cleanly closing Chrome (parent
|
|
290
|
+
playwright-mcp died with SIGKILL/OOM, Chrome reparented to PID 1, profile
|
|
291
|
+
stays locked). Since we just acquired the exclusive lock, any Chrome on
|
|
292
|
+
this profile is an orphan and safe to kill before the caller launches
|
|
293
|
+
a fresh MCP session.
|
|
294
|
+
|
|
295
|
+
The ppid==1 filter is load-bearing: a live peer's Chromium is parented
|
|
296
|
+
to its mcp wrapper (alive). Without the guard, a peer that acquired
|
|
297
|
+
concurrently would SIGTERM the legitimate holder's Chrome and trigger
|
|
298
|
+
crashes like the GPU exit_code=15 we saw on 2026-04-28 14:12 PT.
|
|
299
|
+
|
|
300
|
+
Only fires for `*-browser` locks; no-op for pipeline locks.
|
|
301
|
+
"""
|
|
302
|
+
if not name.endswith("-browser"):
|
|
303
|
+
return
|
|
304
|
+
platform = name[: -len("-browser")]
|
|
305
|
+
agent_marker = f"{platform}-agent.json"
|
|
306
|
+
|
|
307
|
+
def _udd_is_platform_profile(cmd_str: str) -> bool:
|
|
308
|
+
"""True only when --user-data-dir points at the EXACT browser-profiles/<platform>
|
|
309
|
+
dir (or a subdir of it), NOT a sibling like browser-profiles/<platform>-harness.
|
|
310
|
+
|
|
311
|
+
The old test (`f"browser-profiles/{platform}" in cmd`) was a plain substring
|
|
312
|
+
match, so for platform="reddit" it also matched "browser-profiles/reddit-harness"
|
|
313
|
+
and swept the persistent reddit-harness Chrome (launched detached -> ppid=1) on
|
|
314
|
+
every single lock acquire. That is exactly what kept killing the harness mid-cycle
|
|
315
|
+
during the 2026-05-29 migration. Compare the first path component after
|
|
316
|
+
"browser-profiles/" against the platform name instead.
|
|
317
|
+
"""
|
|
318
|
+
marker = "user-data-dir="
|
|
319
|
+
idx = cmd_str.find(marker)
|
|
320
|
+
if idx == -1:
|
|
321
|
+
return False
|
|
322
|
+
val = cmd_str[idx + len(marker):].split(" ", 1)[0].strip().strip('"').strip("'")
|
|
323
|
+
key = "browser-profiles/"
|
|
324
|
+
j = val.find(key)
|
|
325
|
+
if j == -1:
|
|
326
|
+
return False
|
|
327
|
+
seg = val[j + len(key):].split("/", 1)[0]
|
|
328
|
+
return seg == platform
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
r = subprocess.run(
|
|
332
|
+
["ps", "-A", "-o", "pid=,ppid=,command="],
|
|
333
|
+
capture_output=True, text=True, timeout=5,
|
|
334
|
+
)
|
|
335
|
+
except Exception:
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
chrome_pids: list[int] = []
|
|
339
|
+
mcp_pids: list[int] = []
|
|
340
|
+
for line in r.stdout.splitlines():
|
|
341
|
+
# ps output: " PID PPID command..."
|
|
342
|
+
parts = line.strip().split(None, 2)
|
|
343
|
+
if len(parts) < 3:
|
|
344
|
+
continue
|
|
345
|
+
pid_s, ppid_s, cmd = parts
|
|
346
|
+
if ppid_s != "1":
|
|
347
|
+
continue
|
|
348
|
+
if "user-data-dir=" in cmd and _udd_is_platform_profile(cmd):
|
|
349
|
+
try:
|
|
350
|
+
chrome_pids.append(int(pid_s))
|
|
351
|
+
except ValueError:
|
|
352
|
+
pass
|
|
353
|
+
elif agent_marker in cmd:
|
|
354
|
+
try:
|
|
355
|
+
mcp_pids.append(int(pid_s))
|
|
356
|
+
except ValueError:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
for pid in chrome_pids:
|
|
360
|
+
try:
|
|
361
|
+
os.kill(pid, 15) # SIGTERM
|
|
362
|
+
except (ProcessLookupError, PermissionError):
|
|
363
|
+
pass
|
|
364
|
+
except OSError:
|
|
365
|
+
pass
|
|
366
|
+
if chrome_pids:
|
|
367
|
+
print(
|
|
368
|
+
f"# swept orphan Chrome (ppid=1) holding {platform} profile: {chrome_pids}",
|
|
369
|
+
flush=True,
|
|
370
|
+
)
|
|
371
|
+
time.sleep(1)
|
|
372
|
+
|
|
373
|
+
for pid in mcp_pids:
|
|
374
|
+
try:
|
|
375
|
+
os.kill(pid, 15)
|
|
376
|
+
except (ProcessLookupError, PermissionError):
|
|
377
|
+
pass
|
|
378
|
+
except OSError:
|
|
379
|
+
pass
|
|
380
|
+
if mcp_pids:
|
|
381
|
+
print(
|
|
382
|
+
f"# swept orphan MCP wrappers (ppid=1) for {platform}-agent: {mcp_pids}",
|
|
383
|
+
flush=True,
|
|
384
|
+
)
|
|
385
|
+
time.sleep(1)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def cmd_acquire(name: str, timeout: int, ttl: int) -> int:
|
|
389
|
+
lock_dir, pid_file, queue_dir = lock_paths(name)
|
|
390
|
+
queue_dir.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
|
|
392
|
+
owner_pid = find_owner_pid()
|
|
393
|
+
ticket_name = f"{time.time_ns()}-{os.getpid()}"
|
|
394
|
+
ticket_path = queue_dir / ticket_name
|
|
395
|
+
try:
|
|
396
|
+
ticket_path.write_text(f"{owner_pid}\n")
|
|
397
|
+
except Exception as e:
|
|
398
|
+
print(f"ERROR ticket_write_failed:{e}", flush=True)
|
|
399
|
+
return 2
|
|
400
|
+
|
|
401
|
+
waited = 0.0
|
|
402
|
+
try:
|
|
403
|
+
while True:
|
|
404
|
+
gc_stale_tickets(queue_dir)
|
|
405
|
+
tickets = sorted([t.name for t in queue_dir.iterdir() if t.is_file()])
|
|
406
|
+
head = tickets[0] if tickets else None
|
|
407
|
+
if head == ticket_name:
|
|
408
|
+
# Try to acquire by mkdir
|
|
409
|
+
try:
|
|
410
|
+
lock_dir.mkdir()
|
|
411
|
+
# Write expires_at FIRST, then pid_file. Order matters:
|
|
412
|
+
# peers reading a partially-initialized lock during the
|
|
413
|
+
# tiny window between the two writes will see an absent
|
|
414
|
+
# pid_file → `no_pid_file` → treated as stale → safe
|
|
415
|
+
# steal. They never see "valid pid + no TTL" which would
|
|
416
|
+
# mean "respect lock indefinitely".
|
|
417
|
+
write_expires_at(lock_dir, time.time() + ttl)
|
|
418
|
+
pid_file.write_text(f"{owner_pid}\n")
|
|
419
|
+
# Sweep orphan Chrome / MCP wrappers reparented to PID 1
|
|
420
|
+
# before the caller launches a fresh MCP session. Ported
|
|
421
|
+
# from lock.sh:175-198 (2026-05-10) so the bash and Python
|
|
422
|
+
# locks no longer diverge in housekeeping behavior.
|
|
423
|
+
sweep_orphan_browser_processes(name)
|
|
424
|
+
print(
|
|
425
|
+
f"OK owner_pid={owner_pid} waited={waited:.1f} ttl={ttl}",
|
|
426
|
+
flush=True,
|
|
427
|
+
)
|
|
428
|
+
return 0
|
|
429
|
+
except FileExistsError:
|
|
430
|
+
stale, reason = lock_is_stale(lock_dir, pid_file)
|
|
431
|
+
if stale:
|
|
432
|
+
print(f"# steal_stale_lock reason={reason}", flush=True)
|
|
433
|
+
remove_lock(lock_dir)
|
|
434
|
+
continue
|
|
435
|
+
if waited >= timeout:
|
|
436
|
+
# Identify holder for diagnostics
|
|
437
|
+
holder_pid = None
|
|
438
|
+
age = None
|
|
439
|
+
if pid_file.is_file():
|
|
440
|
+
try:
|
|
441
|
+
holder_pid = int(pid_file.read_text().strip() or "0")
|
|
442
|
+
except Exception:
|
|
443
|
+
holder_pid = None
|
|
444
|
+
if lock_dir.is_dir():
|
|
445
|
+
try:
|
|
446
|
+
age = int(time.time() - lock_dir.stat().st_mtime)
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
age = None
|
|
449
|
+
print(
|
|
450
|
+
f"BUSY holder_pid={holder_pid if holder_pid else 'unknown'} "
|
|
451
|
+
f"age={age if age is not None else 'unknown'}s waited={waited:.1f}s",
|
|
452
|
+
flush=True,
|
|
453
|
+
)
|
|
454
|
+
return 1
|
|
455
|
+
time.sleep(POLL_INTERVAL)
|
|
456
|
+
waited += POLL_INTERVAL
|
|
457
|
+
finally:
|
|
458
|
+
# Clean up our ticket regardless of outcome
|
|
459
|
+
try:
|
|
460
|
+
ticket_path.unlink()
|
|
461
|
+
except FileNotFoundError:
|
|
462
|
+
pass
|
|
463
|
+
except Exception:
|
|
464
|
+
pass
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def cmd_release(name: str) -> int:
|
|
468
|
+
lock_dir, pid_file, _ = lock_paths(name)
|
|
469
|
+
if not lock_dir.is_dir():
|
|
470
|
+
print("NOT_HELD", flush=True)
|
|
471
|
+
return 1
|
|
472
|
+
holder_pid = None
|
|
473
|
+
if pid_file.is_file():
|
|
474
|
+
try:
|
|
475
|
+
holder_pid = int(pid_file.read_text().strip() or "0")
|
|
476
|
+
except Exception:
|
|
477
|
+
holder_pid = None
|
|
478
|
+
expected_owner = find_owner_pid()
|
|
479
|
+
if holder_pid is not None and holder_pid != expected_owner and pid_alive(holder_pid):
|
|
480
|
+
# Don't release a lock we didn't acquire
|
|
481
|
+
print(f"HELD_BY_OTHER holder_pid={holder_pid}", flush=True)
|
|
482
|
+
return 1
|
|
483
|
+
remove_lock(lock_dir)
|
|
484
|
+
print("OK", flush=True)
|
|
485
|
+
return 0
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def cmd_heartbeat(name: str, ttl: int) -> int:
|
|
489
|
+
"""Bump the lease expiry. Called by the MCP wrapper on browser activity.
|
|
490
|
+
|
|
491
|
+
Design intent: the heartbeat IS the activity signal. If any reddit-agent
|
|
492
|
+
MCP browser call is happening anywhere on the box, the lease should stay
|
|
493
|
+
alive — independent of which process tree branch is firing the bump. So
|
|
494
|
+
we bump unconditionally as long as the lock dir exists.
|
|
495
|
+
|
|
496
|
+
Why no ownership check: the orchestrator's bash subprocess (which calls
|
|
497
|
+
`acquire`) and the MCP wrapper's heartbeat subprocess descend from
|
|
498
|
+
different parents, so a strict `holder_pid == find_owner_pid()` check
|
|
499
|
+
can falsely reject legit heartbeats in test environments and even in
|
|
500
|
+
edge cases in prod (e.g. when the launchd → script → claude chain
|
|
501
|
+
walks differently for a python subprocess vs. a bash subprocess).
|
|
502
|
+
The lock's correctness is enforced at acquire/release; heartbeat is
|
|
503
|
+
just a "yes, work is happening, don't expire me yet" pulse.
|
|
504
|
+
|
|
505
|
+
Worst case if a peer's wrapper accidentally bumps the holder's lease:
|
|
506
|
+
the holder keeps the lock 90s longer than strictly necessary. Bounded
|
|
507
|
+
by `--ttl`. The peer's `acquire` queue still proceeds in FIFO order
|
|
508
|
+
once activity ceases.
|
|
509
|
+
"""
|
|
510
|
+
lock_dir, _pid_file, _ = lock_paths(name)
|
|
511
|
+
if not lock_dir.is_dir():
|
|
512
|
+
print("NOT_HELD", flush=True)
|
|
513
|
+
return 1
|
|
514
|
+
new_expires = time.time() + ttl
|
|
515
|
+
if not write_expires_at(lock_dir, new_expires):
|
|
516
|
+
print("NOT_HELD", flush=True)
|
|
517
|
+
return 1
|
|
518
|
+
print(f"OK expires_at={new_expires:.0f}", flush=True)
|
|
519
|
+
return 0
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def cmd_status(name: str) -> int:
|
|
523
|
+
lock_dir, pid_file, queue_dir = lock_paths(name)
|
|
524
|
+
info = {
|
|
525
|
+
"name": name,
|
|
526
|
+
"held": False,
|
|
527
|
+
"holder_pid": None,
|
|
528
|
+
"age_sec": None,
|
|
529
|
+
"expires_at": None,
|
|
530
|
+
"ttl_remaining_sec": None,
|
|
531
|
+
"expired": False,
|
|
532
|
+
"queue": [],
|
|
533
|
+
}
|
|
534
|
+
if lock_dir.is_dir():
|
|
535
|
+
info["held"] = True
|
|
536
|
+
if pid_file.is_file():
|
|
537
|
+
try:
|
|
538
|
+
info["holder_pid"] = int(pid_file.read_text().strip() or "0")
|
|
539
|
+
except Exception:
|
|
540
|
+
info["holder_pid"] = None
|
|
541
|
+
try:
|
|
542
|
+
info["age_sec"] = int(time.time() - lock_dir.stat().st_mtime)
|
|
543
|
+
except FileNotFoundError:
|
|
544
|
+
pass
|
|
545
|
+
expires_at = read_expires_at(lock_dir)
|
|
546
|
+
if expires_at is not None:
|
|
547
|
+
info["expires_at"] = expires_at
|
|
548
|
+
remaining = expires_at - time.time()
|
|
549
|
+
info["ttl_remaining_sec"] = round(remaining, 1)
|
|
550
|
+
info["expired"] = remaining <= 0
|
|
551
|
+
if queue_dir.is_dir():
|
|
552
|
+
info["queue"] = sorted([t.name for t in queue_dir.iterdir() if t.is_file()])
|
|
553
|
+
print(json.dumps(info), flush=True)
|
|
554
|
+
return 0
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def main() -> int:
|
|
558
|
+
p = argparse.ArgumentParser(description="Per-post browser lock helper for the reddit-agent profile.")
|
|
559
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
560
|
+
|
|
561
|
+
p_acq = sub.add_parser("acquire", help="Acquire the lock; blocks up to --timeout sec.")
|
|
562
|
+
p_acq.add_argument("--name", default=DEFAULT_NAME)
|
|
563
|
+
p_acq.add_argument("--timeout", type=int, default=DEFAULT_ACQUIRE_TIMEOUT,
|
|
564
|
+
help=f"Max seconds to wait (default {DEFAULT_ACQUIRE_TIMEOUT}).")
|
|
565
|
+
p_acq.add_argument("--ttl", type=int, default=DEFAULT_LEASE_TTL_SECONDS,
|
|
566
|
+
help=f"Initial lease TTL in seconds (default {DEFAULT_LEASE_TTL_SECONDS}). "
|
|
567
|
+
"MCP browser wrapper will heartbeat to keep this fresh during real activity.")
|
|
568
|
+
|
|
569
|
+
p_rel = sub.add_parser("release", help="Release the lock if held by us.")
|
|
570
|
+
p_rel.add_argument("--name", default=DEFAULT_NAME)
|
|
571
|
+
|
|
572
|
+
p_hb = sub.add_parser("heartbeat", help="Extend the lease (called by the MCP wrapper on browser activity).")
|
|
573
|
+
p_hb.add_argument("--name", default=DEFAULT_NAME)
|
|
574
|
+
p_hb.add_argument("--ttl", type=int, default=DEFAULT_LEASE_TTL_SECONDS,
|
|
575
|
+
help=f"Seconds to extend from now (default {DEFAULT_LEASE_TTL_SECONDS}).")
|
|
576
|
+
|
|
577
|
+
p_stat = sub.add_parser("status", help="Print JSON state of the lock.")
|
|
578
|
+
p_stat.add_argument("--name", default=DEFAULT_NAME)
|
|
579
|
+
|
|
580
|
+
args = p.parse_args()
|
|
581
|
+
if args.cmd == "acquire":
|
|
582
|
+
return cmd_acquire(args.name, args.timeout, args.ttl)
|
|
583
|
+
if args.cmd == "release":
|
|
584
|
+
return cmd_release(args.name)
|
|
585
|
+
if args.cmd == "heartbeat":
|
|
586
|
+
return cmd_heartbeat(args.name, args.ttl)
|
|
587
|
+
if args.cmd == "status":
|
|
588
|
+
return cmd_status(args.name)
|
|
589
|
+
return 2
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
if __name__ == "__main__":
|
|
593
|
+
sys.exit(main())
|