@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,211 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
log_twitter_skips.py
|
|
4
|
+
|
|
5
|
+
Writes Phase 2b skip decisions back to twitter_candidates so we can audit why
|
|
6
|
+
each pre-scored candidate was rejected without ever posting.
|
|
7
|
+
|
|
8
|
+
Input shape (stdin or --file):
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"skips": [
|
|
12
|
+
{"candidate_id": 1234, "reason": "off-topic for Mediar"},
|
|
13
|
+
{"candidate_id": 1235, "reason": "thread is toxic crypto promo",
|
|
14
|
+
"proposed_excludes": ["cricket", "kohli", "ipl"]}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Or a bare list of skip objects (same fields).
|
|
19
|
+
|
|
20
|
+
Behavior per row:
|
|
21
|
+
UPDATE twitter_candidates
|
|
22
|
+
SET status = 'skipped',
|
|
23
|
+
skip_reason = <reason, trimmed to 500 chars>,
|
|
24
|
+
skipped_at = NOW()
|
|
25
|
+
WHERE id = <candidate_id>
|
|
26
|
+
AND status = 'pending';
|
|
27
|
+
|
|
28
|
+
Optional `proposed_excludes` (per skip): each term is fed into
|
|
29
|
+
project_excludes.propose() with platform='twitter' and project read from the
|
|
30
|
+
candidate's matched_project column. Validation, reservation guards, and the
|
|
31
|
+
distinct-batch activation gate all live in project_excludes.py — this script
|
|
32
|
+
just forwards the proposals.
|
|
33
|
+
|
|
34
|
+
Pending guard prevents clobbering rows Phase 2b-post already flipped to
|
|
35
|
+
'posted', or rows Phase 0 will salvage on the next cycle. We deliberately do
|
|
36
|
+
NOT touch rows Claude omitted from BOTH chosen and rejected arrays; those are
|
|
37
|
+
treated as "not reviewed" and stay pending so the salvage path can re-judge
|
|
38
|
+
them next cycle.
|
|
39
|
+
|
|
40
|
+
Exit codes:
|
|
41
|
+
0 = ok (even if zero rows updated; script is idempotent)
|
|
42
|
+
1 = malformed input or DB error
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
|
|
50
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
51
|
+
import project_excludes as pe_mod # noqa: E402
|
|
52
|
+
from http_api import api_patch # noqa: E402
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
REASON_MAX = 500
|
|
56
|
+
EXCLUDES_PER_SKIP_CAP = 3 # cap proposed_excludes per rejected entry
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _coerce_payload(raw):
|
|
60
|
+
"""Accept either {"skips": [...]} or a bare list."""
|
|
61
|
+
if isinstance(raw, list):
|
|
62
|
+
return raw
|
|
63
|
+
if isinstance(raw, dict):
|
|
64
|
+
skips = raw.get("skips")
|
|
65
|
+
if isinstance(skips, list):
|
|
66
|
+
return skips
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main():
|
|
71
|
+
parser = argparse.ArgumentParser()
|
|
72
|
+
parser.add_argument("--file", help="Read skip JSON from this file instead of stdin")
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--require-batch-id",
|
|
75
|
+
help="If set, only update candidates whose batch_id matches this value (extra safety)",
|
|
76
|
+
)
|
|
77
|
+
args = parser.parse_args()
|
|
78
|
+
|
|
79
|
+
if args.file:
|
|
80
|
+
with open(args.file) as f:
|
|
81
|
+
raw = json.load(f)
|
|
82
|
+
else:
|
|
83
|
+
text = sys.stdin.read().strip()
|
|
84
|
+
if not text:
|
|
85
|
+
print("log_twitter_skips: empty stdin; nothing to do")
|
|
86
|
+
return 0
|
|
87
|
+
raw = json.loads(text)
|
|
88
|
+
|
|
89
|
+
skips = _coerce_payload(raw)
|
|
90
|
+
if not skips:
|
|
91
|
+
print("log_twitter_skips: no skip entries; nothing to do")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
updated = 0
|
|
95
|
+
no_match = 0
|
|
96
|
+
bad = 0
|
|
97
|
+
seen_ids = set()
|
|
98
|
+
excludes_pending = [] # collect (candidate_id, project, term, batch_id, reason) tuples
|
|
99
|
+
|
|
100
|
+
for entry in skips:
|
|
101
|
+
if not isinstance(entry, dict):
|
|
102
|
+
bad += 1
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
cid = entry.get("candidate_id")
|
|
106
|
+
try:
|
|
107
|
+
cid = int(cid)
|
|
108
|
+
except (TypeError, ValueError):
|
|
109
|
+
bad += 1
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
# Dedupe within this batch in case the model emits the same id twice.
|
|
113
|
+
if cid in seen_ids:
|
|
114
|
+
continue
|
|
115
|
+
seen_ids.add(cid)
|
|
116
|
+
|
|
117
|
+
reason = (entry.get("reason") or "").strip()
|
|
118
|
+
if not reason:
|
|
119
|
+
reason = "unspecified"
|
|
120
|
+
if len(reason) > REASON_MAX:
|
|
121
|
+
reason = reason[: REASON_MAX - 1] + "…"
|
|
122
|
+
|
|
123
|
+
# Server-side WHERE: status='pending' (+ optional batch_id guard) lives
|
|
124
|
+
# inside /api/v1/twitter-candidates/by-id action=mark_skipped. 404 is
|
|
125
|
+
# the "row not pending / batch mismatch" signal; we don't fail the
|
|
126
|
+
# whole batch on it.
|
|
127
|
+
body = {
|
|
128
|
+
"id": cid,
|
|
129
|
+
"action": "mark_skipped",
|
|
130
|
+
"reason": reason,
|
|
131
|
+
}
|
|
132
|
+
if args.require_batch_id:
|
|
133
|
+
body["require_batch_id"] = args.require_batch_id
|
|
134
|
+
resp = api_patch(
|
|
135
|
+
"/api/v1/twitter-candidates/by-id",
|
|
136
|
+
body,
|
|
137
|
+
ok_on_404=True,
|
|
138
|
+
)
|
|
139
|
+
if resp.get("_not_found"):
|
|
140
|
+
no_match += 1
|
|
141
|
+
row_match = False
|
|
142
|
+
else:
|
|
143
|
+
updated += 1
|
|
144
|
+
row_match = True
|
|
145
|
+
|
|
146
|
+
# Stage proposed_excludes for THIS skip (only if status flipped to skipped).
|
|
147
|
+
# The PATCH response carries the full updated row, so we extract
|
|
148
|
+
# matched_project + batch_id from it instead of issuing a second GET.
|
|
149
|
+
proposed = entry.get("proposed_excludes")
|
|
150
|
+
if proposed and isinstance(proposed, list) and row_match:
|
|
151
|
+
data = resp.get("data") or {}
|
|
152
|
+
cand_row = data.get("candidate") or {}
|
|
153
|
+
project = cand_row.get("matched_project")
|
|
154
|
+
cand_batch = cand_row.get("batch_id") or args.require_batch_id
|
|
155
|
+
if project:
|
|
156
|
+
for term in proposed[:EXCLUDES_PER_SKIP_CAP]:
|
|
157
|
+
excludes_pending.append((cid, project, term, cand_batch, reason))
|
|
158
|
+
|
|
159
|
+
# Persist proposed excludes via project_excludes.propose(). Each call has
|
|
160
|
+
# its own DB connection (cheap, the volume is tiny: <=POST_LIMIT*EXCLUDES_PER_SKIP_CAP per cycle).
|
|
161
|
+
pe_inserted = 0
|
|
162
|
+
pe_bumped = 0
|
|
163
|
+
pe_dup = 0
|
|
164
|
+
pe_rejected_invalid = 0
|
|
165
|
+
pe_rejected_reserved = 0
|
|
166
|
+
for cid, project, term, batch_id, reason in excludes_pending:
|
|
167
|
+
try:
|
|
168
|
+
out = pe_mod.propose(
|
|
169
|
+
platform="twitter",
|
|
170
|
+
project=project,
|
|
171
|
+
term=term,
|
|
172
|
+
candidate_id=cid,
|
|
173
|
+
batch_id=batch_id,
|
|
174
|
+
reason=reason,
|
|
175
|
+
)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
print(f"log_twitter_skips: propose error for {project}/{term}: {e}", file=sys.stderr)
|
|
178
|
+
continue
|
|
179
|
+
action = out.get("action")
|
|
180
|
+
if action == "inserted":
|
|
181
|
+
pe_inserted += 1
|
|
182
|
+
elif action == "bumped":
|
|
183
|
+
pe_bumped += 1
|
|
184
|
+
elif action == "duplicate_batch":
|
|
185
|
+
pe_dup += 1
|
|
186
|
+
elif action == "rejected_invalid":
|
|
187
|
+
pe_rejected_invalid += 1
|
|
188
|
+
elif action == "rejected_reserved":
|
|
189
|
+
pe_rejected_reserved += 1
|
|
190
|
+
|
|
191
|
+
print(
|
|
192
|
+
f"log_twitter_skips: updated={updated} no_match={no_match} bad_entries={bad} input={len(skips)}"
|
|
193
|
+
)
|
|
194
|
+
if excludes_pending:
|
|
195
|
+
print(
|
|
196
|
+
f"log_twitter_skips: excludes proposed={len(excludes_pending)} "
|
|
197
|
+
f"inserted={pe_inserted} bumped={pe_bumped} dup_batch={pe_dup} "
|
|
198
|
+
f"invalid={pe_rejected_invalid} reserved={pe_rejected_reserved}"
|
|
199
|
+
)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
try:
|
|
205
|
+
sys.exit(main())
|
|
206
|
+
except json.JSONDecodeError as e:
|
|
207
|
+
print(f"log_twitter_skips: input is not valid JSON: {e}", file=sys.stderr)
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
print(f"log_twitter_skips: error: {e}", file=sys.stderr)
|
|
211
|
+
sys.exit(1)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Look up one of our posts by platform-native ID (tweet_id / activity_id).
|
|
3
|
+
|
|
4
|
+
Used by engage-twitter.sh and engage-linkedin.sh after the engage agent
|
|
5
|
+
navigates a thread, extracts the parent post ID, and needs to resolve which
|
|
6
|
+
project that post belongs to (so it can override replies.project_name and
|
|
7
|
+
draft in the right voice). Replaces the per-prompt OUR_POSTS_INDEX blob
|
|
8
|
+
that was costing 360-573 KB per engage prompt.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python3 scripts/lookup_post.py twitter <tweet_id>
|
|
12
|
+
python3 scripts/lookup_post.py linkedin <activity_id>
|
|
13
|
+
|
|
14
|
+
Output (JSON, single line):
|
|
15
|
+
{"project": "fazm", "our_content": "...full text...", "thread_url": "..."}
|
|
16
|
+
|
|
17
|
+
If no match in the last 30 days of active posts:
|
|
18
|
+
{"project": null}
|
|
19
|
+
|
|
20
|
+
Migrated 2026-06-01 from raw psycopg2 (db.get_conn) to the s4l.ai HTTP API
|
|
21
|
+
(GET /api/v1/posts/lookup?platform=&post_id=). The platform-native id regex
|
|
22
|
+
match (twitter /status/<id>, linkedin urn:li:activity:<id>) now runs server-
|
|
23
|
+
side; the PLATFORM_PATTERNS table is mirrored there. Runs on a machine with
|
|
24
|
+
no DATABASE_URL.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
33
|
+
from http_api import api_get
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Mirrored server-side in /api/v1/posts/lookup (ID_PATTERNS). Kept here only
|
|
37
|
+
# for input validation / unknown-platform rejection before the round trip.
|
|
38
|
+
PLATFORM_PATTERNS = {
|
|
39
|
+
"twitter": r"/status/{id}([^0-9]|$)",
|
|
40
|
+
"x": r"/status/{id}([^0-9]|$)",
|
|
41
|
+
"linkedin": r"urn:li:activity:{id}([^0-9]|$)",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def lookup(platform, post_id):
|
|
46
|
+
if platform.lower() not in PLATFORM_PATTERNS:
|
|
47
|
+
return {"project": None, "error": f"unknown platform: {platform}"}
|
|
48
|
+
|
|
49
|
+
if not re.fullmatch(r"[0-9]+", post_id):
|
|
50
|
+
return {"project": None, "error": "post_id must be digits"}
|
|
51
|
+
|
|
52
|
+
resp = api_get(
|
|
53
|
+
"/api/v1/posts/lookup",
|
|
54
|
+
{"platform": platform.lower(), "post_id": post_id},
|
|
55
|
+
)
|
|
56
|
+
post = (resp.get("data") or {}).get("post")
|
|
57
|
+
if not post:
|
|
58
|
+
return {"project": None}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"project": post.get("project_name"),
|
|
62
|
+
"our_content": post.get("our_content"),
|
|
63
|
+
"thread_url": post.get("thread_url"),
|
|
64
|
+
"posted_at": post.get("posted_at"),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
if len(sys.argv) != 3:
|
|
70
|
+
print(__doc__, file=sys.stderr)
|
|
71
|
+
sys.exit(2)
|
|
72
|
+
platform, post_id = sys.argv[1], sys.argv[2]
|
|
73
|
+
result = lookup(platform, post_id)
|
|
74
|
+
print(json.dumps(result))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stamp web_chat_threads.processed_at = NOW() for a thread (HTTP-only).
|
|
3
|
+
|
|
4
|
+
POST /api/v1/web-chat/threads/<thread_id>/processed. Called by
|
|
5
|
+
skill/check-web-chats.sh at the END of a successful Claude session (exit code
|
|
6
|
+
0), regardless of whether Claude replied or skipped. This is the idempotency
|
|
7
|
+
gate the recovery query in /api/v1/web-chat/unread uses to avoid re-spawning
|
|
8
|
+
Claude on threads that have already been handled.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python3 mark_web_chat_processed.py <thread_id>
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
19
|
+
from http_api import api_post
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
parser = argparse.ArgumentParser()
|
|
24
|
+
parser.add_argument("thread_id")
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
|
|
27
|
+
api_post(f"/api/v1/web-chat/threads/{args.thread_id}/processed", {})
|
|
28
|
+
print(f"marked thread {args.thread_id} processed_at=NOW()")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main()
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mcp_lock_proxy.py — heartbeat wrapper for browser-MCP servers.
|
|
3
|
+
|
|
4
|
+
Spawns a real MCP server (e.g. `npx @playwright/mcp@latest`) as a stdio
|
|
5
|
+
subprocess and proxies JSON-RPC traffic between Claude and that server.
|
|
6
|
+
Whenever a `tools/call` request crosses the wire, the wrapper pushes the
|
|
7
|
+
matching `reddit_browser_lock.py` lease forward by `--ttl` seconds.
|
|
8
|
+
|
|
9
|
+
Why this exists
|
|
10
|
+
---------------
|
|
11
|
+
Pre-2026-05-08 the reddit-browser lock was held for the full duration the
|
|
12
|
+
agent decided to "keep" it. If the agent forgot to call `release`, crashed,
|
|
13
|
+
or did 5+ minutes of non-browser work (page-gen, sleeps, DB updates), the
|
|
14
|
+
lock leaked and every peer reddit pipeline blocked behind a Chrome that
|
|
15
|
+
nobody was using. The fix has two halves:
|
|
16
|
+
|
|
17
|
+
1. The lock now has a `expires_at` lease field (see `reddit_browser_lock.py`).
|
|
18
|
+
If `now() > expires_at`, peers steal it. Default lease = 90s (≫ p99 of
|
|
19
|
+
real reddit-agent MCP call durations, which is 30s).
|
|
20
|
+
2. This wrapper renews the lease on every actual MCP browser call. So as
|
|
21
|
+
long as real browser work is happening the lease stays alive; the moment
|
|
22
|
+
it stops, the lease expires within 90s and peers proceed automatically.
|
|
23
|
+
|
|
24
|
+
Heartbeat strategy
|
|
25
|
+
------------------
|
|
26
|
+
- On every JSON-RPC `tools/call` we see, fire `reddit_browser_lock.py heartbeat`
|
|
27
|
+
in a background thread (so we never block the request).
|
|
28
|
+
- On every response that matches a pending request id, fire heartbeat again.
|
|
29
|
+
- A daemon thread also fires a heartbeat every 30s while at least one request
|
|
30
|
+
is in flight. This covers the rare 5-min `browser_close` / `browser_tabs`
|
|
31
|
+
outliers without bloating the lease window for the common case.
|
|
32
|
+
|
|
33
|
+
Failure modes
|
|
34
|
+
-------------
|
|
35
|
+
- If the lock is currently held by a different owner, heartbeat returns
|
|
36
|
+
`HELD_BY_OTHER` and silently no-ops. We don't try to "fix" it; the actual
|
|
37
|
+
acquire/release logic is the source of truth.
|
|
38
|
+
- If the lock isn't held at all, heartbeat returns `NOT_HELD` and silently
|
|
39
|
+
no-ops. (Browser activity outside the lock is a separate prompt-discipline
|
|
40
|
+
bug; this wrapper isn't where we enforce it.)
|
|
41
|
+
- Heartbeat shells out to a short-lived python3 process. If it hangs or fails,
|
|
42
|
+
the timeout is 5s and we just drop the heartbeat. Worst case: a single MCP
|
|
43
|
+
call doesn't extend the lease — usually fine because subsequent calls
|
|
44
|
+
re-extend it; if it really IS the only call in a window, lease expires
|
|
45
|
+
exactly as the design intends.
|
|
46
|
+
|
|
47
|
+
Args
|
|
48
|
+
----
|
|
49
|
+
mcp_lock_proxy.py [--lock-name reddit-browser] [--ttl 90] -- <real mcp cmd...>
|
|
50
|
+
|
|
51
|
+
Or via env:
|
|
52
|
+
BROWSER_LOCK_NAME=reddit-browser
|
|
53
|
+
BROWSER_LOCK_TTL=90
|
|
54
|
+
BROWSER_LOCK_SCRIPT=/path/to/reddit_browser_lock.py
|
|
55
|
+
|
|
56
|
+
Notes
|
|
57
|
+
-----
|
|
58
|
+
The proxy must be transparent to the MCP protocol. We never modify, drop,
|
|
59
|
+
or reorder messages. We only inspect them to decide whether to fire a
|
|
60
|
+
heartbeat side effect.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import argparse
|
|
66
|
+
import atexit
|
|
67
|
+
import json
|
|
68
|
+
import os
|
|
69
|
+
import signal
|
|
70
|
+
import subprocess
|
|
71
|
+
import sys
|
|
72
|
+
import threading
|
|
73
|
+
import time
|
|
74
|
+
from pathlib import Path
|
|
75
|
+
|
|
76
|
+
REPO_DIR = Path("/Users/matthewdi/social-autoposter")
|
|
77
|
+
DEFAULT_LOCK_SCRIPT = REPO_DIR / "scripts" / "reddit_browser_lock.py"
|
|
78
|
+
DEFAULT_LOCK_NAME = "reddit-browser"
|
|
79
|
+
DEFAULT_TTL = 90
|
|
80
|
+
HEARTBEAT_PULSE_INTERVAL = 30 # while a request is in flight
|
|
81
|
+
|
|
82
|
+
# Tunable via env so other browser agents can re-use this exact wrapper.
|
|
83
|
+
LOCK_NAME = os.environ.get("BROWSER_LOCK_NAME", DEFAULT_LOCK_NAME)
|
|
84
|
+
LOCK_TTL = int(os.environ.get("BROWSER_LOCK_TTL", str(DEFAULT_TTL)))
|
|
85
|
+
LOCK_SCRIPT = Path(os.environ.get("BROWSER_LOCK_SCRIPT", str(DEFAULT_LOCK_SCRIPT)))
|
|
86
|
+
|
|
87
|
+
# Optional debug log for proxy-internal events. Default off.
|
|
88
|
+
DEBUG_LOG_PATH = os.environ.get("BROWSER_LOCK_PROXY_LOG", "")
|
|
89
|
+
DEBUG_LOG_LOCK = threading.Lock()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _log(msg: str) -> None:
|
|
93
|
+
if not DEBUG_LOG_PATH:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
with DEBUG_LOG_LOCK, open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
|
97
|
+
f.write(f"[{time.time():.3f}] {msg}\n")
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---- Heartbeat plumbing -----------------------------------------------------
|
|
103
|
+
|
|
104
|
+
_pending_lock = threading.Lock()
|
|
105
|
+
_pending_request_ids: set = set() # JSON-RPC ids we sent and haven't seen a response for
|
|
106
|
+
_last_heartbeat_at = 0.0
|
|
107
|
+
_heartbeat_min_interval = 1.0 # Don't fire more than once per second
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _fire_heartbeat() -> None:
|
|
111
|
+
"""Shell out to `reddit_browser_lock.py heartbeat`. Always non-blocking."""
|
|
112
|
+
global _last_heartbeat_at
|
|
113
|
+
now = time.time()
|
|
114
|
+
# Cheap throttle: avoid stampedes when a burst of calls fires.
|
|
115
|
+
if now - _last_heartbeat_at < _heartbeat_min_interval:
|
|
116
|
+
return
|
|
117
|
+
_last_heartbeat_at = now
|
|
118
|
+
try:
|
|
119
|
+
subprocess.run(
|
|
120
|
+
[
|
|
121
|
+
sys.executable,
|
|
122
|
+
str(LOCK_SCRIPT),
|
|
123
|
+
"heartbeat",
|
|
124
|
+
"--name",
|
|
125
|
+
LOCK_NAME,
|
|
126
|
+
"--ttl",
|
|
127
|
+
str(LOCK_TTL),
|
|
128
|
+
],
|
|
129
|
+
timeout=5,
|
|
130
|
+
stdout=subprocess.DEVNULL,
|
|
131
|
+
stderr=subprocess.DEVNULL,
|
|
132
|
+
check=False,
|
|
133
|
+
)
|
|
134
|
+
_log(f"heartbeat fired ttl={LOCK_TTL}")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
_log(f"heartbeat failed: {e}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _heartbeat_async() -> None:
|
|
140
|
+
threading.Thread(target=_fire_heartbeat, daemon=True).start()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _pulse_loop() -> None:
|
|
144
|
+
"""Periodic heartbeat while any request is in flight.
|
|
145
|
+
|
|
146
|
+
This handles the rare case where a single MCP call legitimately runs
|
|
147
|
+
longer than the lease TTL (observed max: ~5.6 min on browser_close /
|
|
148
|
+
browser_tabs). Without this loop, that one call would let the lease
|
|
149
|
+
expire mid-flight and a peer would steal the browser from under us.
|
|
150
|
+
"""
|
|
151
|
+
while True:
|
|
152
|
+
time.sleep(HEARTBEAT_PULSE_INTERVAL)
|
|
153
|
+
with _pending_lock:
|
|
154
|
+
has_pending = bool(_pending_request_ids)
|
|
155
|
+
if has_pending:
|
|
156
|
+
_heartbeat_async()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---- JSON-RPC stream proxy --------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _try_parse(line: bytes) -> dict | None:
|
|
163
|
+
if not line:
|
|
164
|
+
return None
|
|
165
|
+
s = line.strip()
|
|
166
|
+
if not s:
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
msg = json.loads(s.decode("utf-8", errors="replace"))
|
|
170
|
+
if isinstance(msg, dict):
|
|
171
|
+
return msg
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _proxy_stdin_to_proc(proc: subprocess.Popen) -> None:
|
|
178
|
+
"""Read JSON-RPC from our stdin (Claude → us), forward to the MCP server.
|
|
179
|
+
|
|
180
|
+
Inspect for `tools/call` requests; record id and fire heartbeat.
|
|
181
|
+
"""
|
|
182
|
+
while True:
|
|
183
|
+
try:
|
|
184
|
+
line = sys.stdin.buffer.readline()
|
|
185
|
+
except Exception as e:
|
|
186
|
+
_log(f"stdin read error: {e}")
|
|
187
|
+
break
|
|
188
|
+
if not line:
|
|
189
|
+
break
|
|
190
|
+
msg = _try_parse(line)
|
|
191
|
+
if msg is not None:
|
|
192
|
+
method = msg.get("method")
|
|
193
|
+
req_id = msg.get("id")
|
|
194
|
+
if method == "tools/call" and req_id is not None:
|
|
195
|
+
with _pending_lock:
|
|
196
|
+
_pending_request_ids.add(req_id)
|
|
197
|
+
_heartbeat_async()
|
|
198
|
+
_log(f"req in id={req_id}")
|
|
199
|
+
try:
|
|
200
|
+
proc.stdin.write(line)
|
|
201
|
+
proc.stdin.flush()
|
|
202
|
+
except (BrokenPipeError, ValueError):
|
|
203
|
+
break
|
|
204
|
+
except Exception as e:
|
|
205
|
+
_log(f"forward to subprocess failed: {e}")
|
|
206
|
+
break
|
|
207
|
+
try:
|
|
208
|
+
proc.stdin.close()
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _proxy_proc_to_stdout(proc: subprocess.Popen) -> None:
|
|
214
|
+
"""Read MCP server stdout, forward to our stdout (us → Claude).
|
|
215
|
+
|
|
216
|
+
Inspect for response objects matching pending request ids; on match,
|
|
217
|
+
drop the id from the pending set and fire one final heartbeat (so the
|
|
218
|
+
lease covers the moment the call resolved).
|
|
219
|
+
"""
|
|
220
|
+
while True:
|
|
221
|
+
try:
|
|
222
|
+
line = proc.stdout.readline()
|
|
223
|
+
except Exception as e:
|
|
224
|
+
_log(f"subprocess stdout read error: {e}")
|
|
225
|
+
break
|
|
226
|
+
if not line:
|
|
227
|
+
break
|
|
228
|
+
msg = _try_parse(line)
|
|
229
|
+
if msg is not None:
|
|
230
|
+
resp_id = msg.get("id")
|
|
231
|
+
if resp_id is not None and "method" not in msg:
|
|
232
|
+
with _pending_lock:
|
|
233
|
+
if resp_id in _pending_request_ids:
|
|
234
|
+
_pending_request_ids.discard(resp_id)
|
|
235
|
+
_log(f"resp out id={resp_id}")
|
|
236
|
+
_heartbeat_async()
|
|
237
|
+
try:
|
|
238
|
+
sys.stdout.buffer.write(line)
|
|
239
|
+
sys.stdout.buffer.flush()
|
|
240
|
+
except (BrokenPipeError, ValueError):
|
|
241
|
+
break
|
|
242
|
+
except Exception as e:
|
|
243
|
+
_log(f"forward to claude failed: {e}")
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---- Subprocess lifecycle ---------------------------------------------------
|
|
248
|
+
|
|
249
|
+
_subprocess_handle: subprocess.Popen | None = None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _cleanup_subprocess() -> None:
|
|
253
|
+
"""Make sure the wrapped MCP server dies if our proxy goes away.
|
|
254
|
+
|
|
255
|
+
Without this, killing the proxy (e.g. when claude exits abnormally) would
|
|
256
|
+
leave `npx @playwright/mcp@latest` and its Chrome child running, which
|
|
257
|
+
permanently holds the reddit browser profile lock.
|
|
258
|
+
"""
|
|
259
|
+
p = _subprocess_handle
|
|
260
|
+
if p is None:
|
|
261
|
+
return
|
|
262
|
+
try:
|
|
263
|
+
if p.poll() is None:
|
|
264
|
+
try:
|
|
265
|
+
p.terminate()
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
try:
|
|
269
|
+
p.wait(timeout=2)
|
|
270
|
+
except Exception:
|
|
271
|
+
try:
|
|
272
|
+
p.kill()
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _signal_exit(signum, _frame) -> None:
|
|
280
|
+
_log(f"received signal {signum}, exiting")
|
|
281
|
+
_cleanup_subprocess()
|
|
282
|
+
# Use os._exit to skip atexit (already ran cleanup) and avoid stuck threads.
|
|
283
|
+
os._exit(0)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---- Entrypoint -------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def main() -> int:
|
|
290
|
+
# Hoist `global` declarations to the very top of main() so argparse
|
|
291
|
+
# defaults below can reference module-level values without Python's
|
|
292
|
+
# "used prior to global declaration" SyntaxError.
|
|
293
|
+
global LOCK_NAME, LOCK_TTL, LOCK_SCRIPT
|
|
294
|
+
|
|
295
|
+
p = argparse.ArgumentParser(
|
|
296
|
+
description="Heartbeat wrapper for a browser-MCP stdio server.",
|
|
297
|
+
allow_abbrev=False,
|
|
298
|
+
)
|
|
299
|
+
p.add_argument("--lock-name", default=LOCK_NAME)
|
|
300
|
+
p.add_argument("--ttl", type=int, default=LOCK_TTL)
|
|
301
|
+
p.add_argument(
|
|
302
|
+
"--lock-script", default=str(LOCK_SCRIPT),
|
|
303
|
+
help="Path to reddit_browser_lock.py (or compatible).",
|
|
304
|
+
)
|
|
305
|
+
p.add_argument(
|
|
306
|
+
"real_cmd", nargs=argparse.REMAINDER,
|
|
307
|
+
help="The real MCP server command (everything after `--`).",
|
|
308
|
+
)
|
|
309
|
+
args = p.parse_args()
|
|
310
|
+
|
|
311
|
+
# Apply CLI overrides (env was already read at import; CLI wins).
|
|
312
|
+
LOCK_NAME = args.lock_name
|
|
313
|
+
LOCK_TTL = args.ttl
|
|
314
|
+
LOCK_SCRIPT = Path(args.lock_script)
|
|
315
|
+
|
|
316
|
+
# Strip a leading `--` separator if argparse left it in REMAINDER.
|
|
317
|
+
real_cmd = list(args.real_cmd)
|
|
318
|
+
if real_cmd and real_cmd[0] == "--":
|
|
319
|
+
real_cmd = real_cmd[1:]
|
|
320
|
+
if not real_cmd:
|
|
321
|
+
print(
|
|
322
|
+
"mcp_lock_proxy: missing real MCP server command. "
|
|
323
|
+
"Pass it after `--`, e.g. `mcp_lock_proxy.py -- npx @playwright/mcp@latest ...`",
|
|
324
|
+
file=sys.stderr,
|
|
325
|
+
)
|
|
326
|
+
return 2
|
|
327
|
+
|
|
328
|
+
_log(f"starting wrapper lock_name={LOCK_NAME} ttl={LOCK_TTL} cmd={real_cmd}")
|
|
329
|
+
|
|
330
|
+
# Install lifecycle hooks BEFORE spawning the child, so any spawn-time
|
|
331
|
+
# crash still triggers cleanup.
|
|
332
|
+
atexit.register(_cleanup_subprocess)
|
|
333
|
+
try:
|
|
334
|
+
signal.signal(signal.SIGTERM, _signal_exit)
|
|
335
|
+
signal.signal(signal.SIGINT, _signal_exit)
|
|
336
|
+
signal.signal(signal.SIGHUP, _signal_exit)
|
|
337
|
+
except (ValueError, OSError):
|
|
338
|
+
# Not all platforms allow handler installation in non-main threads.
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
global _subprocess_handle
|
|
342
|
+
try:
|
|
343
|
+
_subprocess_handle = subprocess.Popen(
|
|
344
|
+
real_cmd,
|
|
345
|
+
stdin=subprocess.PIPE,
|
|
346
|
+
stdout=subprocess.PIPE,
|
|
347
|
+
stderr=sys.stderr,
|
|
348
|
+
bufsize=0,
|
|
349
|
+
)
|
|
350
|
+
proc = _subprocess_handle
|
|
351
|
+
except FileNotFoundError as e:
|
|
352
|
+
print(f"mcp_lock_proxy: failed to spawn real MCP server: {e}", file=sys.stderr)
|
|
353
|
+
return 127
|
|
354
|
+
|
|
355
|
+
t_in = threading.Thread(target=_proxy_stdin_to_proc, args=(proc,), daemon=True)
|
|
356
|
+
t_out = threading.Thread(target=_proxy_proc_to_stdout, args=(proc,), daemon=True)
|
|
357
|
+
t_pulse = threading.Thread(target=_pulse_loop, daemon=True)
|
|
358
|
+
t_in.start()
|
|
359
|
+
t_out.start()
|
|
360
|
+
t_pulse.start()
|
|
361
|
+
|
|
362
|
+
rc = proc.wait()
|
|
363
|
+
# Give the output thread a moment to flush any tail.
|
|
364
|
+
t_out.join(timeout=1.0)
|
|
365
|
+
_log(f"wrapper exiting rc={rc}")
|
|
366
|
+
return rc
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
sys.exit(main())
|