@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,384 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scan a logged-in user's X/Twitter profile to build a "grounding truth" corpus
|
|
3
|
+
for the setup wizard.
|
|
4
|
+
|
|
5
|
+
WHERE THIS FITS: right after setup's connect_x detects the real @handle, we
|
|
6
|
+
already have an authenticated CDP session on the autoposter's managed Chrome
|
|
7
|
+
(port 9555). This script reuses that session to read three surfaces of the
|
|
8
|
+
user's own profile:
|
|
9
|
+
|
|
10
|
+
1. profile header -> name, bio, location, url, follower/following, pinned
|
|
11
|
+
2. posts tab -> up to ~20 original posts (their authentic voice)
|
|
12
|
+
3. /with_replies -> up to ~50 of their own replies/comments (how they talk
|
|
13
|
+
TO people, which is what the autoposter actually does)
|
|
14
|
+
|
|
15
|
+
It does NOT synthesize anything. It returns one JSON blob (the corpus) plus a
|
|
16
|
+
`grounding_instructions` block. The setup *conversation* (the host agent already
|
|
17
|
+
interviewing the user) reads that and drafts the config fields (voice,
|
|
18
|
+
differentiator, icp, search_topics) in the user's own register, then confirms
|
|
19
|
+
with the user before writing config.json. Keeping synthesis in the conversation
|
|
20
|
+
(not a nested `claude -p`) is deliberate: it stays conversational and lets the
|
|
21
|
+
user correct the read before anything is saved.
|
|
22
|
+
|
|
23
|
+
Read-only. Never posts, never clicks, never writes config. Attaches to the
|
|
24
|
+
EXISTING managed Chrome; never launches a login flow.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
python3 scripts/scan_x_profile.py [--handle m13v_] [--posts 20] [--comments 50]
|
|
28
|
+
# --handle optional: if omitted, reads the live logged-in handle from the DOM.
|
|
29
|
+
|
|
30
|
+
Output (stdout, last line is JSON):
|
|
31
|
+
{"ok": true, "handle": "...", "profile": {...}, "posts": [...],
|
|
32
|
+
"comments": [...], "counts": {...}, "grounding_instructions": "..."}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
import time
|
|
42
|
+
import urllib.request
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from websocket import create_connection # websocket-client
|
|
46
|
+
except Exception: # pragma: no cover
|
|
47
|
+
create_connection = None # type: ignore[assignment]
|
|
48
|
+
|
|
49
|
+
CDP = os.environ.get(
|
|
50
|
+
"S4L_TWITTER_CDP_URL",
|
|
51
|
+
os.environ.get("TWITTER_CDP_URL", "http://127.0.0.1:9555"),
|
|
52
|
+
).rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------- #
|
|
56
|
+
# CDP attach (mirrors setup_twitter_auth.py::_attach so behavior is identical).
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
def _attach():
|
|
59
|
+
targets = json.load(urllib.request.urlopen(f"{CDP}/json", timeout=10))
|
|
60
|
+
page = next((t for t in targets if t.get("type") == "page"), None)
|
|
61
|
+
if not page:
|
|
62
|
+
page = json.load(
|
|
63
|
+
urllib.request.urlopen(
|
|
64
|
+
urllib.request.Request(f"{CDP}/json/new?about:blank", method="PUT"),
|
|
65
|
+
timeout=10,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
ws = create_connection(page["webSocketDebuggerUrl"], timeout=30, suppress_origin=True)
|
|
69
|
+
state = {"id": 0}
|
|
70
|
+
|
|
71
|
+
def send(method, params=None):
|
|
72
|
+
state["id"] += 1
|
|
73
|
+
ws.send(json.dumps({"id": state["id"], "method": method, "params": params or {}}))
|
|
74
|
+
while True:
|
|
75
|
+
msg = json.loads(ws.recv())
|
|
76
|
+
if msg.get("id") == state["id"]:
|
|
77
|
+
return msg
|
|
78
|
+
|
|
79
|
+
return ws, send
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _eval(send, expr: str):
|
|
83
|
+
r = send("Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True})
|
|
84
|
+
return (r.get("result", {}).get("result", {}) or {}).get("value")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _current_url(send) -> str:
|
|
88
|
+
return _eval(send, "location.href") or ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _navigate(send, url: str, settle: float = 3.5, expect: "str | None" = None,
|
|
92
|
+
attempts: int = 3) -> bool:
|
|
93
|
+
"""Navigate and (optionally) assert we actually landed on `expect` (a substring
|
|
94
|
+
of the URL). The managed Chrome is shared with the posting cycle, so another
|
|
95
|
+
process can yank the page mid-load; retry instead of scraping the wrong page.
|
|
96
|
+
Returns True if the expected URL was reached (or no expectation given)."""
|
|
97
|
+
send("Page.enable")
|
|
98
|
+
for _ in range(attempts):
|
|
99
|
+
send("Page.navigate", {"url": url})
|
|
100
|
+
time.sleep(settle)
|
|
101
|
+
if not expect:
|
|
102
|
+
return True
|
|
103
|
+
for _ in range(6):
|
|
104
|
+
if expect in (_current_url(send) or ""):
|
|
105
|
+
return True
|
|
106
|
+
time.sleep(1.5)
|
|
107
|
+
return expect in (_current_url(send) or "")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------------------- #
|
|
111
|
+
# Live handle (when --handle not passed). Same selectors as setup_twitter_auth.
|
|
112
|
+
# --------------------------------------------------------------------------- #
|
|
113
|
+
_HANDLE_JS = r"""(function(){
|
|
114
|
+
function fromHref(sel){var a=document.querySelector(sel);if(a){var h=a.getAttribute('href')||'';var m=h.match(/^\/([A-Za-z0-9_]{1,15})$/);if(m)return m[1];}return '';}
|
|
115
|
+
var h=fromHref('a[data-testid="AppTabBar_Profile_Link"]');
|
|
116
|
+
if(h)return h;
|
|
117
|
+
var b=document.querySelector('[data-testid="SideNav_AccountSwitcher_Button"]');
|
|
118
|
+
if(b){var m=(b.textContent||'').match(/@([A-Za-z0-9_]{1,15})/);if(m)return m[1];}
|
|
119
|
+
return '';
|
|
120
|
+
})()"""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_live_handle(send) -> "str | None":
|
|
124
|
+
u = _current_url(send)
|
|
125
|
+
if "x.com" not in u and "twitter.com" not in u:
|
|
126
|
+
_navigate(send, "https://x.com/home")
|
|
127
|
+
for _ in range(8):
|
|
128
|
+
v = (_eval(send, _HANDLE_JS) or "").strip().lstrip("@")
|
|
129
|
+
if v:
|
|
130
|
+
return v
|
|
131
|
+
time.sleep(1)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------- #
|
|
136
|
+
# Profile header scrape.
|
|
137
|
+
# --------------------------------------------------------------------------- #
|
|
138
|
+
_PROFILE_JS = r"""(function(){
|
|
139
|
+
function txt(sel){var e=document.querySelector(sel);return e?(e.innerText||'').trim():'';}
|
|
140
|
+
var name=txt('[data-testid="UserName"] span');
|
|
141
|
+
var bio=txt('[data-testid="UserDescription"]');
|
|
142
|
+
var loc=txt('[data-testid="UserLocation"]');
|
|
143
|
+
var url=txt('[data-testid="UserUrl"]');
|
|
144
|
+
var join=txt('[data-testid="UserJoinDate"]');
|
|
145
|
+
// follower / following counts (anchors ending in /verified_followers, /followers, /following)
|
|
146
|
+
function count(suffix){
|
|
147
|
+
var a=document.querySelector('a[href$="/'+suffix+'"]');
|
|
148
|
+
if(!a)return '';
|
|
149
|
+
var s=(a.innerText||'').trim();
|
|
150
|
+
var m=s.match(/([\d.,]+[KMB]?)/);
|
|
151
|
+
return m?m[1]:s;
|
|
152
|
+
}
|
|
153
|
+
var following=count('following');
|
|
154
|
+
var followers=count('verified_followers')||count('followers');
|
|
155
|
+
// pinned tweet text (first article carrying a "Pinned" socialContext)
|
|
156
|
+
var pinned='';
|
|
157
|
+
var arts=document.querySelectorAll('article');
|
|
158
|
+
for(var i=0;i<arts.length;i++){
|
|
159
|
+
var sc=arts[i].querySelector('[data-testid="socialContext"]');
|
|
160
|
+
if(sc && /pinned/i.test(sc.innerText||'')){
|
|
161
|
+
var t=arts[i].querySelector('[data-testid="tweetText"]');
|
|
162
|
+
pinned=t?(t.innerText||'').trim():'';
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return JSON.stringify({name:name,bio:bio,location:loc,url:url,join:join,followers:followers,following:following,pinned:pinned});
|
|
167
|
+
})()"""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def scrape_profile(send) -> dict:
|
|
171
|
+
raw = _eval(send, _PROFILE_JS) or "{}"
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(raw)
|
|
174
|
+
except Exception:
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --------------------------------------------------------------------------- #
|
|
179
|
+
# Timeline scrape (posts tab OR /with_replies). Scrolls, dedupes by tweet URL,
|
|
180
|
+
# classifies each article as authored-post vs reply, keeps only the user's OWN
|
|
181
|
+
# articles (drops reposts/quotes of other accounts that show on the main tab).
|
|
182
|
+
# --------------------------------------------------------------------------- #
|
|
183
|
+
_TIMELINE_JS_TMPL = r"""(function(){
|
|
184
|
+
var ME=%s; // lowercase handle without @
|
|
185
|
+
var out=[];
|
|
186
|
+
var arts=document.querySelectorAll('article');
|
|
187
|
+
for(var i=0;i<arts.length;i++){
|
|
188
|
+
var art=arts[i];
|
|
189
|
+
// author handle for THIS article
|
|
190
|
+
var authorHandle='';
|
|
191
|
+
var links=art.querySelectorAll('a[href^="/"]');
|
|
192
|
+
for(var j=0;j<links.length;j++){
|
|
193
|
+
var hh=links[j].getAttribute('href')||'';
|
|
194
|
+
var mm=hh.match(/^\/([A-Za-z0-9_]{1,15})$/);
|
|
195
|
+
if(mm){authorHandle=mm[1].toLowerCase();break;}
|
|
196
|
+
}
|
|
197
|
+
if(authorHandle && authorHandle!==ME) continue; // skip others' posts (reposts/quotes/threads)
|
|
198
|
+
var tEl=art.querySelector('[data-testid="tweetText"]');
|
|
199
|
+
var text=tEl?(tEl.innerText||'').trim():'';
|
|
200
|
+
if(!text) continue;
|
|
201
|
+
// permalink + id
|
|
202
|
+
var url='';var id='';
|
|
203
|
+
var statusLinks=art.querySelectorAll('a[href*="/status/"]');
|
|
204
|
+
for(var k=0;k<statusLinks.length;k++){
|
|
205
|
+
var sh=statusLinks[k].getAttribute('href')||'';
|
|
206
|
+
var sm=sh.match(/\/status\/(\d+)/);
|
|
207
|
+
if(sm){id=sm[1];url='https://x.com'+sh.split('?')[0];break;}
|
|
208
|
+
}
|
|
209
|
+
// reply? presence of a "Replying to" header in the cell
|
|
210
|
+
var isReply=false, replyTo='';
|
|
211
|
+
var spans=art.querySelectorAll('span,div');
|
|
212
|
+
for(var s=0;s<spans.length;s++){
|
|
213
|
+
var st=(spans[s].innerText||'');
|
|
214
|
+
if(/^Replying to/i.test(st)){
|
|
215
|
+
isReply=true;
|
|
216
|
+
var rm=st.match(/@([A-Za-z0-9_]{1,15})/);
|
|
217
|
+
if(rm)replyTo='@'+rm[1];
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// engagement (best-effort from aria-labels)
|
|
222
|
+
function metric(name){
|
|
223
|
+
var b=art.querySelector('[data-testid="'+name+'"]');
|
|
224
|
+
if(!b)return 0;
|
|
225
|
+
var al=b.getAttribute('aria-label')||'';
|
|
226
|
+
var m=al.match(/([\d,]+)/);
|
|
227
|
+
return m?parseInt(m[1].replace(/,/g,''),10):0;
|
|
228
|
+
}
|
|
229
|
+
out.push({text:text,url:url,id:id,is_reply:isReply,reply_to:replyTo,
|
|
230
|
+
likes:metric('like'),replies:metric('reply'),retweets:metric('retweet')});
|
|
231
|
+
}
|
|
232
|
+
return JSON.stringify(out);
|
|
233
|
+
})()"""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def scrape_timeline(send, me: str, want: int, max_scrolls: int = 30,
|
|
237
|
+
exclude_ids: "set | None" = None) -> list:
|
|
238
|
+
"""Scroll the current timeline, collecting up to `want` of the user's OWN
|
|
239
|
+
authored articles (in DOM order = newest first). `exclude_ids` drops items
|
|
240
|
+
already captured elsewhere — that's how the comments pass (/with_replies)
|
|
241
|
+
subtracts the original posts to leave just replies. We do NOT rely on a
|
|
242
|
+
'Replying to' header: the profile /with_replies timeline doesn't render one
|
|
243
|
+
per article, so post-vs-reply is decided by set subtraction, not DOM text.
|
|
244
|
+
|
|
245
|
+
End-of-feed is detected by COLLECTED-COUNT STALL, not scrollHeight: x.com
|
|
246
|
+
virtualizes the timeline (unloads off-screen articles and keeps total height
|
|
247
|
+
~constant while swapping content), so scrollHeight plateaus even mid-feed and
|
|
248
|
+
would false-trigger an early stop. We instead stop when no NEW item has been
|
|
249
|
+
captured for `STALL_LIMIT` consecutive scrolls (after a min number of scrolls),
|
|
250
|
+
scrolling to the bottom each step to force the next lazy-load batch."""
|
|
251
|
+
seen: dict[str, dict] = {}
|
|
252
|
+
exclude_ids = exclude_ids or set()
|
|
253
|
+
expr = _TIMELINE_JS_TMPL % json.dumps(me.lower())
|
|
254
|
+
STALL_LIMIT = 4
|
|
255
|
+
stall = 0
|
|
256
|
+
for n in range(max_scrolls):
|
|
257
|
+
raw = _eval(send, expr) or "[]"
|
|
258
|
+
try:
|
|
259
|
+
batch = json.loads(raw)
|
|
260
|
+
except Exception:
|
|
261
|
+
batch = []
|
|
262
|
+
before = len(seen)
|
|
263
|
+
for item in batch:
|
|
264
|
+
key = item.get("id") or item.get("url") or item.get("text", "")[:80]
|
|
265
|
+
if not key or key in seen or key in exclude_ids:
|
|
266
|
+
continue
|
|
267
|
+
seen[key] = item
|
|
268
|
+
if len(seen) >= want:
|
|
269
|
+
break
|
|
270
|
+
# No new items this pass? Count it as a stall. Give the feed a few
|
|
271
|
+
# consecutive empty scrolls (lazy-load can lag) before declaring the end.
|
|
272
|
+
if len(seen) == before and n > 0:
|
|
273
|
+
stall += 1
|
|
274
|
+
if stall >= STALL_LIMIT:
|
|
275
|
+
break
|
|
276
|
+
else:
|
|
277
|
+
stall = 0
|
|
278
|
+
# Scroll to the bottom of currently-loaded content to trigger the next
|
|
279
|
+
# batch, then wait for it to render before the next read.
|
|
280
|
+
_eval(send, "window.scrollTo(0, document.documentElement.scrollHeight);")
|
|
281
|
+
time.sleep(2.0)
|
|
282
|
+
items = list(seen.values())
|
|
283
|
+
return items[:want]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
GROUNDING_INSTRUCTIONS = (
|
|
287
|
+
"You now have this user's real X profile (bio, original posts, and their own "
|
|
288
|
+
"replies). Use it as GROUND TRUTH to draft their autoposter config fields in "
|
|
289
|
+
"THEIR register, not a generic marketing voice. Specifically:\n"
|
|
290
|
+
"1. PROFESSION & IDENTITY: from the bio + what they post about, state who they "
|
|
291
|
+
"are and what they do. This anchors `description`/`differentiator`.\n"
|
|
292
|
+
"2. VOICE & VIBE: read the actual posts/replies and capture HOW they talk, the "
|
|
293
|
+
"tone (dry, hype, technical, warm, terse, profane, formal), sentence length, "
|
|
294
|
+
"capitalization habits, emoji/punctuation usage, and recurring phrases or tics. "
|
|
295
|
+
"Write the `voice` field so a reply drafted with it would be indistinguishable "
|
|
296
|
+
"from something they'd actually type.\n"
|
|
297
|
+
"3. GOLDEN-RULE EXAMPLES: pick 2-4 of their strongest real replies/posts "
|
|
298
|
+
"verbatim and keep them as exemplars (these become few-shot anchors). Choose "
|
|
299
|
+
"ones that show the target reply behavior: helpful, specific, in-voice.\n"
|
|
300
|
+
"4. PHRASE BANK: list the kinds of phrases / openers / sign-offs they reuse, "
|
|
301
|
+
"and any words/claims they clearly AVOID (for `content_guardrails`).\n"
|
|
302
|
+
"5. ICP: infer who they engage with (who they reply to, what communities) to "
|
|
303
|
+
"draft `icp`.\n"
|
|
304
|
+
"6. SEARCH TOPICS: pull the recurring themes/keywords from their posts into "
|
|
305
|
+
"`search_topics` (these literally seed the X searches the cycle runs).\n"
|
|
306
|
+
"Then SHOW the user your read ('here's the voice/topics I picked up from your "
|
|
307
|
+
"profile, does this sound like you?') and only call setup to save after they "
|
|
308
|
+
"confirm or correct it. Never invent traits the corpus doesn't support."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def main() -> int:
|
|
313
|
+
ap = argparse.ArgumentParser()
|
|
314
|
+
ap.add_argument("--handle", default=None, help="@handle to scan (default: live logged-in handle)")
|
|
315
|
+
ap.add_argument("--posts", type=int, default=20, help="max original posts to collect")
|
|
316
|
+
ap.add_argument("--comments", type=int, default=50, help="max replies/comments to collect")
|
|
317
|
+
args = ap.parse_args()
|
|
318
|
+
|
|
319
|
+
if create_connection is None:
|
|
320
|
+
print(json.dumps({"ok": False, "state": "error",
|
|
321
|
+
"error": "websocket-client not installed (needed for CDP)."}))
|
|
322
|
+
return 1
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
ws, send = _attach()
|
|
326
|
+
except Exception as e:
|
|
327
|
+
print(json.dumps({"ok": False, "state": "browser_not_running",
|
|
328
|
+
"error": f"Could not attach to managed Chrome on {CDP}: {e}. "
|
|
329
|
+
"Run setup action:'connect_x' first."}))
|
|
330
|
+
return 1
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
send("Page.enable")
|
|
334
|
+
send("Runtime.enable")
|
|
335
|
+
|
|
336
|
+
handle = (args.handle or "").strip().lstrip("@") or _resolve_live_handle(send)
|
|
337
|
+
if not handle:
|
|
338
|
+
print(json.dumps({"ok": False, "state": "no_handle",
|
|
339
|
+
"error": "Could not determine the logged-in X handle. "
|
|
340
|
+
"Confirm X is connected (setup action:'connect_x')."}))
|
|
341
|
+
return 1
|
|
342
|
+
|
|
343
|
+
# 1. Profile header (also lands us on the posts tab).
|
|
344
|
+
on_profile = _navigate(send, f"https://x.com/{handle}", settle=4.0,
|
|
345
|
+
expect=f"/{handle}")
|
|
346
|
+
profile = scrape_profile(send) if on_profile else {}
|
|
347
|
+
|
|
348
|
+
# 2. Original posts (current page = posts tab).
|
|
349
|
+
posts = scrape_timeline(send, handle, args.posts) if on_profile else []
|
|
350
|
+
post_ids = {p.get("id") for p in posts if p.get("id")}
|
|
351
|
+
|
|
352
|
+
# 3. Replies / comments = the user's own articles on /with_replies that
|
|
353
|
+
# are NOT among the original posts (set subtraction, not DOM text).
|
|
354
|
+
on_replies = _navigate(send, f"https://x.com/{handle}/with_replies",
|
|
355
|
+
settle=4.0, expect=f"/{handle}/with_replies")
|
|
356
|
+
comments = (
|
|
357
|
+
scrape_timeline(send, handle, args.comments, exclude_ids=post_ids)
|
|
358
|
+
if on_replies else []
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
result = {
|
|
362
|
+
"ok": True,
|
|
363
|
+
"state": "scanned",
|
|
364
|
+
"handle": handle,
|
|
365
|
+
"profile": profile,
|
|
366
|
+
"posts": posts,
|
|
367
|
+
"comments": comments,
|
|
368
|
+
"counts": {"posts": len(posts), "comments": len(comments)},
|
|
369
|
+
"grounding_instructions": GROUNDING_INSTRUCTIONS,
|
|
370
|
+
}
|
|
371
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
372
|
+
return 0
|
|
373
|
+
except Exception as e:
|
|
374
|
+
print(json.dumps({"ok": False, "state": "error", "error": str(e)}))
|
|
375
|
+
return 1
|
|
376
|
+
finally:
|
|
377
|
+
try:
|
|
378
|
+
ws.close()
|
|
379
|
+
except Exception:
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
if __name__ == "__main__":
|
|
384
|
+
sys.exit(main())
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Single source of truth for the draft-autopilot schedule state.
|
|
3
|
+
|
|
4
|
+
Both the Node MCP server (mcp/src/index.ts::scheduleState, via subprocess) and
|
|
5
|
+
the Python menu bar (mcp/menubar/s4l_menubar.py, via in-process import) read the
|
|
6
|
+
schedule state from HERE so the two surfaces can never drift. Previously the same
|
|
7
|
+
~40-line algorithm was hand-maintained in both languages.
|
|
8
|
+
|
|
9
|
+
The data source is the host's scheduled-task registries on disk:
|
|
10
|
+
~/Library/Application Support/Claude/claude-code-sessions/*/*/scheduled-tasks.json
|
|
11
|
+
A complete worker set must be present: the universal s4l-worker task (or its short-lived
|
|
12
|
+
staging predecessor saps-worker), or the
|
|
13
|
+
legacy pair (saps-phase1-query + saps-phase2b-draft) on pre-universal installs.
|
|
14
|
+
The LIVE account is the registry whose tasks have the freshest lastRunAt (only
|
|
15
|
+
the active account's scheduler advances it, so this is robust across the
|
|
16
|
+
session-id churn that Claude restarts cause).
|
|
17
|
+
|
|
18
|
+
States:
|
|
19
|
+
'ok' — a complete worker set present, enabled, and FIRING (lastRunAt
|
|
20
|
+
within FIRING_WINDOW seconds).
|
|
21
|
+
'disabled' — present but a worker task is disabled.
|
|
22
|
+
'missing' — not firing anywhere (orphaned / not registered for the live
|
|
23
|
+
account) -> the dashboard offers "Set up draft schedule".
|
|
24
|
+
|
|
25
|
+
stdlib-only on purpose, so the MCP can run it with system python3 before the
|
|
26
|
+
owned runtime is provisioned. Run as a script -> prints {"state": "..."} as JSON.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import glob
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
# SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists and
|
|
37
|
+
# scheduled-task prompts still export SAPS_*; this process reads S4L_*.
|
|
38
|
+
import s4l_env # noqa: E402 (lives next to this file in scripts/)
|
|
39
|
+
|
|
40
|
+
s4l_env.mirror()
|
|
41
|
+
|
|
42
|
+
# Keep in sync with QUEUE_WORKERS / LEGACY_QUEUE_WORKER_TASK_IDS in
|
|
43
|
+
# mcp/src/index.ts and WORKER_TASK_IDS in mcp/menubar/s4l_menubar.py.
|
|
44
|
+
# A registry counts as scheduled when ANY complete set is present: the
|
|
45
|
+
# universal type-blind worker (2026-07-02, single task drains every job type)
|
|
46
|
+
# or the legacy per-type pair from pre-universal installs.
|
|
47
|
+
WORKER_TASK_SETS = (
|
|
48
|
+
("s4l-worker",),
|
|
49
|
+
("saps-worker",), # transitional: staging rc.2/rc.3 only, pre brand rename
|
|
50
|
+
("saps-phase1-query", "saps-phase2b-draft"),
|
|
51
|
+
)
|
|
52
|
+
# Flat legacy alias; s4l_menubar imports this for its relocation sweep.
|
|
53
|
+
WORKER_TASK_IDS = ("s4l-worker", "saps-worker", "saps-phase1-query", "saps-phase2b-draft")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _registry_worker_recs(by_id):
|
|
57
|
+
"""Records for the first COMPLETE worker set in this registry, or None."""
|
|
58
|
+
for task_ids in WORKER_TASK_SETS:
|
|
59
|
+
recs = [by_id.get(tid) for tid in task_ids]
|
|
60
|
+
if all(r is not None for r in recs):
|
|
61
|
+
return recs
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
# A worker task whose lastRunAt is within this many seconds counts as "firing".
|
|
65
|
+
# 7 min tolerates the host's per-task throttle + Claude restart gaps without a
|
|
66
|
+
# false "not scheduled".
|
|
67
|
+
FIRING_WINDOW = 420
|
|
68
|
+
|
|
69
|
+
# Grace for a JUST-scheduled task that hasn't fired yet. When the user runs
|
|
70
|
+
# "Set up draft schedule", create_scheduled_task registers both worker tasks
|
|
71
|
+
# (cron "* * * * *", enabled) but lastRunAt is null until the host fires them the
|
|
72
|
+
# first time — up to a minute or two later, longer if the launchd kicker is still
|
|
73
|
+
# installing. Without this grace, compute() saw newest_epoch=None and returned
|
|
74
|
+
# "missing", so the menu bar kept flashing ⚠ right after the user successfully
|
|
75
|
+
# scheduled. If a freshly-created, enabled task hasn't fired yet but was created
|
|
76
|
+
# within this window, treat it as "ok" (waiting for first fire, not orphaned).
|
|
77
|
+
# 15 min comfortably covers a slow first fire; a genuinely dead schedule still
|
|
78
|
+
# flips to ⚠ once the grace lapses. Orphaned tasks from an old account carry a
|
|
79
|
+
# stale createdAt, so they never fall inside this window.
|
|
80
|
+
CREATED_GRACE = 900
|
|
81
|
+
|
|
82
|
+
# "Claude*" (not "Claude"): the host app can run with a custom --user-data-dir
|
|
83
|
+
# (per-account dirs like "Claude-mediar" on multi-account machines), and the
|
|
84
|
+
# registry lands under THAT dir while plain "Claude/" has no claude-code-sessions
|
|
85
|
+
# at all. The freshest-lastRunAt selection in compute() already picks the live
|
|
86
|
+
# registry among however many dirs match, so widening the glob is safe; the
|
|
87
|
+
# plain-"Claude" glob read a hard "missing" forever on such machines even while
|
|
88
|
+
# both worker tasks fired every minute (found 2026-07-02 during onboarding).
|
|
89
|
+
SCHED_REGISTRY_GLOB = os.path.join(
|
|
90
|
+
os.path.expanduser("~"), "Library", "Application Support", "Claude*",
|
|
91
|
+
"claude-code-sessions", "*", "*", "scheduled-tasks.json",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _iso_to_epoch(s):
|
|
96
|
+
if not s:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
import calendar
|
|
100
|
+
return calendar.timegm(
|
|
101
|
+
time.strptime(str(s).strip().rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S")
|
|
102
|
+
)
|
|
103
|
+
except Exception:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ms_to_epoch(ms):
|
|
108
|
+
"""createdAt is epoch MILLISECONDS in the registry; return epoch seconds."""
|
|
109
|
+
try:
|
|
110
|
+
return float(ms) / 1000.0
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def compute(glob_pattern: str = SCHED_REGISTRY_GLOB) -> str:
|
|
116
|
+
"""Return 'ok' | 'disabled' | 'missing' for the live account's draft schedule."""
|
|
117
|
+
newest_epoch, newest_enabled = None, False
|
|
118
|
+
# Track the freshest just-created, enabled, never-yet-fired task so a schedule
|
|
119
|
+
# the user only moments ago set up doesn't read as "missing" before its first
|
|
120
|
+
# fire lands (see CREATED_GRACE).
|
|
121
|
+
newest_fresh_created = None
|
|
122
|
+
any_present, any_enabled = False, False
|
|
123
|
+
for f in glob.glob(glob_pattern):
|
|
124
|
+
try:
|
|
125
|
+
with open(f) as fh:
|
|
126
|
+
d = json.load(fh)
|
|
127
|
+
except Exception:
|
|
128
|
+
continue
|
|
129
|
+
by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
|
|
130
|
+
recs = _registry_worker_recs(by_id)
|
|
131
|
+
if recs is None:
|
|
132
|
+
continue
|
|
133
|
+
any_present = True
|
|
134
|
+
enabled = all(r.get("enabled") for r in recs)
|
|
135
|
+
any_enabled = any_enabled or enabled
|
|
136
|
+
epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
|
|
137
|
+
e = max([x for x in epochs if x is not None], default=None)
|
|
138
|
+
if e is not None and (newest_epoch is None or e > newest_epoch):
|
|
139
|
+
newest_epoch, newest_enabled = e, enabled
|
|
140
|
+
# Freshly scheduled + enabled + not yet fired anywhere in this registry.
|
|
141
|
+
if enabled and e is None:
|
|
142
|
+
created = [_ms_to_epoch(r.get("createdAt")) for r in recs]
|
|
143
|
+
c = min([x for x in created if x is not None], default=None)
|
|
144
|
+
if c is not None and (newest_fresh_created is None or c > newest_fresh_created):
|
|
145
|
+
newest_fresh_created = c
|
|
146
|
+
# Firing recently => the live account's schedule is active and healthy.
|
|
147
|
+
if newest_epoch is not None and (time.time() - newest_epoch) <= FIRING_WINDOW:
|
|
148
|
+
return "ok" if newest_enabled else "disabled"
|
|
149
|
+
# Just scheduled, enabled, waiting for its first fire => "ok" (not orphaned).
|
|
150
|
+
# Suppresses the false ⚠ right after the user sets up the draft schedule.
|
|
151
|
+
if newest_fresh_created is not None and (time.time() - newest_fresh_created) <= CREATED_GRACE:
|
|
152
|
+
return "ok"
|
|
153
|
+
# Not firing anywhere. Registered-but-disabled => disabled; else missing.
|
|
154
|
+
if any_present and not any_enabled:
|
|
155
|
+
return "disabled"
|
|
156
|
+
return "missing"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _detail(glob_pattern: str = SCHED_REGISTRY_GLOB) -> dict:
|
|
160
|
+
"""Cheap diagnostics for the JSON output: which registries the glob saw and
|
|
161
|
+
which contain both worker tasks, with each one's freshest lastRunAt age.
|
|
162
|
+
This is what makes a 'missing' verdict debuggable from a log line instead of
|
|
163
|
+
requiring filesystem forensics (the 2026-07-02 rotated-dir bug hid here)."""
|
|
164
|
+
regs = []
|
|
165
|
+
for f in glob.glob(glob_pattern):
|
|
166
|
+
entry = {"path": f, "has_workers": False, "last_run_age_s": None}
|
|
167
|
+
try:
|
|
168
|
+
with open(f) as fh:
|
|
169
|
+
d = json.load(fh)
|
|
170
|
+
by_id = {t.get("id"): t for t in (d.get("scheduledTasks") or [])}
|
|
171
|
+
recs = _registry_worker_recs(by_id)
|
|
172
|
+
if recs is not None:
|
|
173
|
+
entry["has_workers"] = True
|
|
174
|
+
epochs = [_iso_to_epoch(r.get("lastRunAt")) for r in recs]
|
|
175
|
+
e = max([x for x in epochs if x is not None], default=None)
|
|
176
|
+
if e is not None:
|
|
177
|
+
entry["last_run_age_s"] = int(time.time() - e)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
entry["error"] = str(exc)
|
|
180
|
+
regs.append(entry)
|
|
181
|
+
return {"glob": glob_pattern, "registries": regs}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main() -> int:
|
|
185
|
+
out = {}
|
|
186
|
+
try:
|
|
187
|
+
out["state"] = compute()
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
out["state"] = "missing"
|
|
190
|
+
out["error"] = str(exc)
|
|
191
|
+
# Extra keys are ignored by the Node caller (it reads .state only) but give
|
|
192
|
+
# menubar Sentry captures and hand-runs the WHY behind a 'missing'.
|
|
193
|
+
try:
|
|
194
|
+
out["detail"] = _detail()
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
print(json.dumps(out))
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|