@m13v/s4l 1.7.1-rc.2 → 1.7.1-rc.21

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.
Files changed (45) hide show
  1. package/bin/cli.js +23 -0
  2. package/mcp/dist/index.js +332 -24
  3. package/mcp/dist/onboarding.js +3 -10
  4. package/mcp/dist/panel.html +42 -31
  5. package/mcp/dist/product-link.html +1 -1
  6. package/mcp/dist/runtime.js +101 -1
  7. package/mcp/dist/telemetry.js +80 -3
  8. package/mcp/dist/version.json +2 -2
  9. package/mcp/manifest.json +9 -1
  10. package/mcp/menubar/dashboard_server.py +12 -0
  11. package/mcp/menubar/s4l_card.py +472 -96
  12. package/mcp/menubar/s4l_menubar.py +269 -26
  13. package/mcp/menubar/s4l_state.py +169 -23
  14. package/mcp/package.json +1 -1
  15. package/mcp/shared/doctor.cjs +8 -0
  16. package/mcp/shared/onboarding-ledger.cjs +1 -0
  17. package/package.json +3 -1
  18. package/scripts/active_users.py +3 -6
  19. package/scripts/autopilot_stall_watch.py +200 -16
  20. package/scripts/claude_job.py +6 -0
  21. package/scripts/feedback_digest.py +34 -6
  22. package/scripts/get-latest-staging-mcpb.sh +50 -0
  23. package/scripts/harness_overlay.py +45 -0
  24. package/scripts/identity.py +26 -0
  25. package/scripts/learned_preferences.py +241 -32
  26. package/scripts/link_tail.py +167 -91
  27. package/scripts/mark_event.py +128 -0
  28. package/scripts/memory_snapshot.py +62 -0
  29. package/scripts/pick_twitter_thread_target.py +1 -1
  30. package/scripts/release-mcpb.sh +48 -0
  31. package/scripts/reset-test-machine.sh +85 -3
  32. package/scripts/s4l_mode.py +7 -5
  33. package/scripts/scheduled_tasks_snapshot.py +107 -0
  34. package/scripts/send_gmail_report.py +76 -0
  35. package/scripts/sentry_digest.py +81 -189
  36. package/scripts/sentry_init.py +43 -15
  37. package/scripts/setup_twitter_auth.py +76 -38
  38. package/scripts/snapshot.py +20 -0
  39. package/scripts/twitter_post_plan.py +39 -13
  40. package/skill/dm-outreach-twitter.sh +19 -0
  41. package/skill/engage-twitter.sh +27 -0
  42. package/skill/run-draft-and-publish.sh +13 -12
  43. package/skill/run-linkedin.sh +9 -1
  44. package/skill/run-twitter-cycle.sh +199 -27
  45. package/skill/sentry-digest.sh +125 -12
@@ -1,36 +1,33 @@
1
1
  #!/usr/bin/env python3
2
- """Sentry issue digest for the s4l Sentry project (org mediar-n5).
3
-
4
- Replaces the raw per-issue Sentry alert email. That alert rule
5
- (mediar-n5/s4l rule id 17212931) fired on every first-seen issue and every
6
- high-priority mark regardless of severity, so it also emailed on level=warning
7
- menubar operational pings ("draft_stuck", "missing", "rate_limited", "review
8
- card unattended"). Those still exist in Sentry, still get investigated
9
- on-demand via the "Debugging a customer install" playbook (see CLAUDE.md),
10
- they just no longer push a raw email. This script is scoped to level:error and
11
- level:fatal only ("critical Sentry issues" per user instruction 2026-07-07):
12
- real Python exceptions and pipeline failures, not the synthetic warning pings.
13
-
14
- Impact ranking uses distinct install_id count, not Sentry's built-in
15
- userCount, because s4l events are tagged per-install (install_id), not
16
- per-Sentry-user (userCount is 0 across the board for this project).
17
-
18
- Idempotency / noise control: a JSON ledger at scripts/state/sentry_digest_ledger.json
19
- tracks last-seen event/install counts per issue. A digest email is only sent
20
- when something is NEW (not in the ledger) or GROWING (event count up 20%+ and
21
- by at least 5 events since the ledger snapshot). First run baselines every
22
- open critical issue without flagging all of them as new.
2
+ """Sentry issue scanner for the s4l Sentry project (org mediar-n5).
3
+
4
+ Pull-only step in the sentry-digest pipeline (see skill/sentry-digest.sh and
5
+ skill/SENTRY-DIGEST-SKILL.md). This script does NOT investigate, does NOT
6
+ email, and does NOT write the ledger. It just:
7
+
8
+ 1. Pulls unresolved level:error/level:fatal issues ("critical Sentry issues"
9
+ per user instruction 2026-07-07; level:warning menubar pings like
10
+ "draft_stuck" are excluded, they're a different kind of event already
11
+ surfaced elsewhere).
12
+ 2. Fetches distinct install_id impact per issue (Sentry's built-in userCount
13
+ is 0 across the board for this project; installs are tagged via
14
+ install_id instead).
15
+ 3. Diffs against the ledger to classify each issue as NEW / GROWING / STABLE.
16
+ 4. Writes the full scan result to a JSON file on disk and prints a one-line
17
+ summary for the bash wrapper to decide whether to spawn Claude for
18
+ investigation.
19
+
20
+ The actual investigation (read the crashing code, check git log, judge
21
+ actionability, draft a human-readable email, send it, write the ledger back)
22
+ is done by a spawned Claude Code session per skill/SENTRY-DIGEST-SKILL.md,
23
+ mirroring how ~/fazm/inbox/skill/check-query-insights.sh divides labor
24
+ between a mechanical pull step and an investigative Claude step.
23
25
 
24
26
  Usage:
25
- python3 scripts/sentry_digest.py # normal run (used by launchd)
26
- python3 scripts/sentry_digest.py --dry-run # print what would happen, no email, no ledger write
27
-
28
- Patterned after strike_alert.py: same Gmail token, same dash-scrubbing,
29
- default recipient i@m13v.com.
27
+ python3 scripts/sentry_digest.py --out /path/to/scan.json
30
28
  """
31
29
 
32
30
  import argparse
33
- import base64
34
31
  import json
35
32
  import os
36
33
  import subprocess
@@ -39,39 +36,26 @@ import urllib.error
39
36
  import urllib.parse
40
37
  import urllib.request
41
38
  from datetime import datetime, timezone
42
- from email.mime.text import MIMEText
43
39
 
44
40
  SENTRY_ORG = "mediar-n5"
45
41
  SENTRY_PROJECT = "s4l"
46
42
  SENTRY_PROJECT_ID = "4511598804336640"
47
43
  SENTRY_API = "https://sentry.io/api/0"
48
44
 
49
- # "Critical Sentry issues" = level:error or level:fatal. Excludes level:warning
50
- # (the synthetic menubar/autopilot signal pings), which are a different kind
51
- # of event and already have their own surfacing (menubar UI, dashboard,
52
- # on-demand Sentry queries during customer debugging).
45
+ # "Critical Sentry issues" = level:error or level:fatal.
53
46
  ISSUE_QUERY = "is:unresolved level:[error,fatal]"
54
47
 
55
48
  REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
56
49
  STATE_DIR = os.path.join(REPO_DIR, "scripts", "state")
57
50
  LEDGER_PATH = os.path.join(STATE_DIR, "sentry_digest_ledger.json")
58
51
 
59
- GMAIL_TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
60
- GMAIL_SCOPES = ["https://mail.google.com/"]
61
- NOTIFICATION_EMAIL = os.environ.get("NOTIFICATION_EMAIL", "i@m13v.com")
62
-
63
- # Growth threshold: an issue re-alerts if event count grew by at least this
64
- # many events AND by at least this relative fraction since the ledger snapshot.
52
+ # Growth threshold: an issue counts as GROWING if event count grew by at
53
+ # least this many events AND by at least this relative fraction since the
54
+ # ledger snapshot.
65
55
  GROWTH_MIN_DELTA = 5
66
56
  GROWTH_MIN_RATIO = 1.2
67
57
 
68
58
 
69
- def _scrub_dashes(s):
70
- if not s:
71
- return s
72
- return s.replace("—", ",").replace("–", ",")
73
-
74
-
75
59
  def _sentry_token():
76
60
  env_token = os.environ.get("SENTRY_AUTH_TOKEN")
77
61
  if env_token:
@@ -103,16 +87,16 @@ def fetch_critical_issues(token):
103
87
 
104
88
 
105
89
  def fetch_install_impact(issue_id, token):
106
- """Distinct install_id count + top installs for one issue. Returns (count, top)."""
90
+ """Distinct install_id count for one issue. Returns 0 if the tag is absent."""
107
91
  try:
108
92
  data = _sentry_get(f"/issues/{issue_id}/tags/install_id/", token)
109
- return data.get("uniqueValues", 0), data.get("topValues", [])
93
+ return data.get("uniqueValues", 0)
110
94
  except urllib.error.HTTPError as e:
111
95
  if e.code == 404:
112
- return 0, [] # tag not present on any event for this issue
96
+ return 0
113
97
  raise
114
98
  except Exception:
115
- return 0, []
99
+ return 0
116
100
 
117
101
 
118
102
  def load_ledger():
@@ -125,95 +109,13 @@ def load_ledger():
125
109
  return {"version": 1, "lastUpdated": None, "issues": {}}
126
110
 
127
111
 
128
- def save_ledger(ledger):
129
- os.makedirs(STATE_DIR, exist_ok=True)
130
- tmp = LEDGER_PATH + ".tmp"
131
- with open(tmp, "w") as f:
132
- json.dump(ledger, f, indent=2)
133
- os.replace(tmp, LEDGER_PATH)
134
-
135
-
136
112
  def issue_link(short_id):
137
113
  return f"https://mediar-n5.sentry.io/issues/?project={SENTRY_PROJECT_ID}&query={short_id}"
138
114
 
139
115
 
140
- def gmail_service():
141
- from google.auth.transport.requests import Request
142
- from google.oauth2.credentials import Credentials
143
- from googleapiclient.discovery import build
144
-
145
- creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
146
- if creds.expired and creds.refresh_token:
147
- creds.refresh(Request())
148
- with open(GMAIL_TOKEN_PATH, "w") as f:
149
- f.write(creds.to_json())
150
- return build("gmail", "v1", credentials=creds)
151
-
152
-
153
- def send_email(service, to_addr, subject, html_body):
154
- msg = MIMEText(html_body, "html")
155
- msg["to"] = to_addr
156
- msg["subject"] = _scrub_dashes(subject)
157
- raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
158
- result = service.users().messages().send(userId="me", body={"raw": raw}).execute()
159
- return result["id"]
160
-
161
-
162
- def build_rows_html(rows, growth=False):
163
- cells = []
164
- for r in rows:
165
- link = issue_link(r["shortId"])
166
- if growth:
167
- cells.append(
168
- f"<tr><td><a href='{link}'>{r['shortId']}</a>: {r['title']}</td>"
169
- f"<td>{r['prevCount']} &rarr; {r['count']}</td>"
170
- f"<td>{r['prevInstalls']} &rarr; {r['installs']}</td></tr>"
171
- )
172
- else:
173
- cells.append(
174
- f"<tr><td><a href='{link}'>{r['shortId']}</a>: {r['title']}</td>"
175
- f"<td>{r['count']}</td><td>{r['installs']}</td></tr>"
176
- )
177
- return "".join(cells)
178
-
179
-
180
- def build_html(new_rows, growing_rows, first_run, total_open):
181
- today = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
182
- sections = [f"<p><b>Critical Sentry issues (level:error/fatal), s4l project.</b> "
183
- f"Open total: {total_open}. Generated {today}.</p>"]
184
-
185
- if first_run:
186
- sections.append("<p>First run: baselining every open critical issue. No investigation, "
187
- "just a snapshot. Future runs only flag NEW or GROWING issues.</p>")
188
-
189
- if new_rows and not first_run:
190
- sections.append("<h3>New issues</h3><table border='1' cellpadding='6' "
191
- "style='border-collapse:collapse'><tr><th>Issue</th><th>Events</th>"
192
- "<th>Installs</th></tr>" + build_rows_html(new_rows) + "</table>")
193
-
194
- if growing_rows:
195
- sections.append("<h3>Growing issues</h3><table border='1' cellpadding='6' "
196
- "style='border-collapse:collapse'><tr><th>Issue</th>"
197
- "<th>Events (was &rarr; now)</th><th>Installs (was &rarr; now)</th></tr>"
198
- + build_rows_html(growing_rows, growth=True) + "</table>")
199
-
200
- if first_run:
201
- top = sorted(new_rows, key=lambda r: -r["installs"])[:10]
202
- sections.append("<h3>Top 10 by installs affected (baseline snapshot)</h3>"
203
- "<table border='1' cellpadding='6' style='border-collapse:collapse'>"
204
- "<tr><th>Issue</th><th>Events</th><th>Installs</th></tr>"
205
- + build_rows_html(top) + "</table>")
206
-
207
- sections.append("<p style='color:#888;font-size:12px'>Ranked by distinct install_id count, "
208
- "not Sentry's userCount (unset for this project). level:warning menubar "
209
- "signals are excluded; they're a different kind of event and still queryable "
210
- "in Sentry directly during customer debugging.</p>")
211
- return "<div style='font-family:sans-serif;max-width:800px'>" + "".join(sections) + "</div>"
212
-
213
-
214
116
  def main():
215
117
  parser = argparse.ArgumentParser()
216
- parser.add_argument("--dry-run", action="store_true", help="Print plan, do not send email or write ledger.")
118
+ parser.add_argument("--out", required=True, help="Path to write the scan result JSON.")
217
119
  args = parser.parse_args()
218
120
 
219
121
  token = _sentry_token()
@@ -229,74 +131,64 @@ def main():
229
131
  ledger = load_ledger()
230
132
  known = ledger.get("issues", {})
231
133
  first_run = len(known) == 0
232
- now_iso = datetime.now(timezone.utc).isoformat()
233
134
 
234
- new_rows, growing_rows = [], []
235
- updated_issues = dict(known)
135
+ scanned = []
136
+ new_count = growing_count = 0
236
137
 
237
138
  for issue in issues:
238
139
  short_id = issue["shortId"]
140
+ numeric_id = issue["id"]
239
141
  count = int(issue.get("count", 0))
240
- installs, _top = fetch_install_impact(issue["id"], token)
142
+ installs = fetch_install_impact(numeric_id, token)
241
143
  prev = known.get(short_id)
242
144
 
243
145
  if prev is None:
244
- new_rows.append({"shortId": short_id, "title": issue["title"][:90], "count": count, "installs": installs})
146
+ classification = "new"
147
+ new_count += 1
245
148
  else:
246
149
  prev_count = int(prev.get("lastEventCount", 0))
247
- if not first_run and count - prev_count >= GROWTH_MIN_DELTA and prev_count > 0 and count >= prev_count * GROWTH_MIN_RATIO:
248
- growing_rows.append({
249
- "shortId": short_id, "title": issue["title"][:90],
250
- "count": count, "prevCount": prev_count,
251
- "installs": installs, "prevInstalls": prev.get("lastInstallCount", 0),
252
- })
253
-
254
- updated_issues[short_id] = {
255
- "title": issue["title"][:200],
256
- "lastEventCount": count,
257
- "lastInstallCount": installs,
258
- "firstSeenRun": (prev or {}).get("firstSeenRun", now_iso),
259
- "lastSeenRun": now_iso,
260
- }
261
-
262
- print(f"firstRun={first_run} newCount={len(new_rows)} growingCount={len(growing_rows)} totalOpen={len(issues)}")
263
-
264
- should_email = first_run or new_rows or growing_rows
265
- if not should_email:
266
- print("Nothing new or growing. No email.")
267
- if not args.dry_run:
268
- ledger["issues"] = updated_issues
269
- ledger["lastUpdated"] = now_iso
270
- save_ledger(ledger)
271
- return
272
-
273
- if first_run:
274
- subject = f"[Sentry] s4l critical-issue digest live: {len(issues)} baselined"
275
- elif new_rows and growing_rows:
276
- subject = f"[Sentry] s4l: {len(new_rows)} new, {len(growing_rows)} growing critical issue(s)"
277
- elif new_rows:
278
- top_new = max(new_rows, key=lambda r: r["installs"])
279
- subject = f"[Sentry] s4l: new critical issue, {top_new['shortId']} ({top_new['installs']} installs)"
280
- else:
281
- top_grow = max(growing_rows, key=lambda r: r["installs"])
282
- subject = f"[Sentry] s4l: {top_grow['shortId']} growing ({top_grow['prevCount']} -> {top_grow['count']} events)"
283
-
284
- html = build_html(new_rows, growing_rows, first_run, len(issues))
285
-
286
- print(f"Subject: {subject}")
287
- if args.dry_run:
288
- print("--dry-run: not sending email, not writing ledger.")
289
- print(html)
290
- return
291
-
292
- service = gmail_service()
293
- msg_id = send_email(service, NOTIFICATION_EMAIL, subject, html)
294
- print(f"Email sent: {msg_id}")
295
-
296
- ledger["issues"] = updated_issues
297
- ledger["lastUpdated"] = now_iso
298
- save_ledger(ledger)
299
- print("Ledger written.")
150
+ is_growing = (
151
+ not first_run
152
+ and count - prev_count >= GROWTH_MIN_DELTA
153
+ and prev_count > 0
154
+ and count >= prev_count * GROWTH_MIN_RATIO
155
+ )
156
+ if is_growing:
157
+ classification = "growing"
158
+ growing_count += 1
159
+ else:
160
+ classification = "stable"
161
+
162
+ scanned.append({
163
+ "shortId": short_id,
164
+ "numericId": numeric_id,
165
+ "title": issue["title"],
166
+ "level": issue.get("level"),
167
+ "count": count,
168
+ "prevCount": int(prev["lastEventCount"]) if prev else None,
169
+ "installs": installs,
170
+ "prevInstalls": prev.get("lastInstallCount") if prev else None,
171
+ "classification": classification,
172
+ "link": issue_link(short_id),
173
+ "lastSeen": issue.get("lastSeen"),
174
+ "firstSeen": issue.get("firstSeen"),
175
+ })
176
+
177
+ result = {
178
+ "scannedAt": datetime.now(timezone.utc).isoformat(),
179
+ "firstRun": first_run,
180
+ "totalOpen": len(issues),
181
+ "newCount": new_count,
182
+ "growingCount": growing_count,
183
+ "ledgerPath": LEDGER_PATH,
184
+ "issues": scanned,
185
+ }
186
+
187
+ os.makedirs(os.path.dirname(args.out), exist_ok=True)
188
+ with open(args.out, "w") as f:
189
+ json.dump(result, f, indent=2)
190
+
191
+ print(f"firstRun={first_run} newCount={new_count} growingCount={growing_count} totalOpen={len(issues)} out={args.out}")
300
192
 
301
193
 
302
194
  if __name__ == "__main__":
@@ -18,6 +18,24 @@ import os
18
18
  _EMBEDDED_DSN = "https://4d44ac907262c6545cf8681703528d04@o4507617161314304.ingest.us.sentry.io/4511598804336640"
19
19
 
20
20
  _initialized = False
21
+ _install_id_cache: str | None = None
22
+
23
+
24
+ def _install_id() -> str | None:
25
+ """Cached install_id lookup, used to fingerprint events per-install (see
26
+ capture_exception/capture_message). Best-effort: "" (not None) is cached on
27
+ failure so a broken identity.py doesn't re-attempt the read on every call."""
28
+ global _install_id_cache
29
+ if _install_id_cache is not None:
30
+ return _install_id_cache or None
31
+ try:
32
+ from identity import get_identity
33
+
34
+ ident = get_identity() or {}
35
+ _install_id_cache = str(ident.get("install_id") or "")
36
+ except Exception:
37
+ _install_id_cache = ""
38
+ return _install_id_cache or None
21
39
 
22
40
 
23
41
  def init() -> None:
@@ -79,12 +97,17 @@ def capture_exception(err, tags=None) -> None:
79
97
  except Exception:
80
98
  return
81
99
  try:
82
- if tags:
83
- with sentry_sdk.push_scope() as scope:
84
- for k, v in tags.items():
85
- scope.set_tag(str(k), str(v))
86
- sentry_sdk.capture_exception(err)
87
- else:
100
+ with sentry_sdk.push_scope() as scope:
101
+ for k, v in (tags or {}).items():
102
+ scope.set_tag(str(k), str(v))
103
+ # Mix install_id into the grouping key so the SAME exception on two
104
+ # different customer boxes lands in two different issues, not one
105
+ # conflated issue whose "latest event" can silently belong to a
106
+ # different install than the one you filtered for (default grouping
107
+ # is by exception type + location, which ignores tags entirely).
108
+ iid = _install_id()
109
+ if iid:
110
+ scope.fingerprint = ["{{ default }}", iid]
88
111
  sentry_sdk.capture_exception(err)
89
112
  except Exception:
90
113
  return
@@ -100,7 +123,12 @@ def capture_message(message, level="error", tags=None, extra=None) -> None:
100
123
  `extra` is a dict of larger structured values (e.g. the scheduled-task
101
124
  registry summary) attached as event extras — tags are capped at 200 chars
102
125
  and would truncate them. Added 2026-07-06: the "needs attention: missing"
103
- events carried no registry detail, which cost hours on the Karol case."""
126
+ events carried no registry detail, which cost hours on the Karol case.
127
+
128
+ Fingerprint: mixed with install_id (see capture_exception) — capture_message
129
+ events group by the message TEXT by default, so two installs hitting the
130
+ identically-worded stall alert (e.g. "autopilot stalled... sustained N
131
+ checks") would otherwise merge into one issue and hide per-install history."""
104
132
  if not _initialized:
105
133
  return
106
134
  try:
@@ -108,14 +136,14 @@ def capture_message(message, level="error", tags=None, extra=None) -> None:
108
136
  except Exception:
109
137
  return
110
138
  try:
111
- if tags or extra:
112
- with sentry_sdk.push_scope() as scope:
113
- for k, v in (tags or {}).items():
114
- scope.set_tag(str(k), str(v))
115
- for k, v in (extra or {}).items():
116
- scope.set_extra(str(k), v)
117
- sentry_sdk.capture_message(str(message), level=level)
118
- else:
139
+ with sentry_sdk.push_scope() as scope:
140
+ for k, v in (tags or {}).items():
141
+ scope.set_tag(str(k), str(v))
142
+ for k, v in (extra or {}).items():
143
+ scope.set_extra(str(k), v)
144
+ iid = _install_id()
145
+ if iid:
146
+ scope.fingerprint = ["{{ default }}", iid]
119
147
  sentry_sdk.capture_message(str(message), level=level)
120
148
  except Exception:
121
149
  return
@@ -1027,17 +1027,50 @@ def cmd_connect(args) -> dict:
1027
1027
  except Exception as e:
1028
1028
  return {"ok": False, "connected": False, "state": "error", "error": str(e), "cdp": CDP}
1029
1029
 
1030
- # 1b. Headless + Keychain pre-flight (#3 + #4, added 2026-06-02).
1031
- # On macOS, copy_browser_cookies.py needs to read the per-browser Safe
1032
- # Storage entry from the OS keychain. SSH-invoked processes get
1033
- # errSecAuthFailed silently — no prompt, no warning. We probe up front so
1034
- # the user sees "your keychain is locked / run unlock-keychain" instead of
1035
- # the misleading "log in manually" cascade.
1036
- headless = _is_headless()
1037
- if headless:
1038
- # Probe with the first source's likely browser label. We don't know
1039
- # which source will succeed yet, so probe Chrome (the autoposter
1040
- # default); if that's denied, all the AUTO_SOURCES will be too.
1030
+ # 1b. Choose the connection PATH before doing any keychain work. There are
1031
+ # two mutually exclusive paths, and only one of them touches the keychain:
1032
+ # - IMPORT: copy an existing x.com session out of the user's everyday
1033
+ # browser. This reads that browser's "Safe Storage" key from the macOS
1034
+ # Keychain, so it (and ONLY it) is gated by the headless keychain
1035
+ # preflight below.
1036
+ # - MANUAL LOGIN: open the managed Chrome's own login page and let the
1037
+ # user sign in by hand. Reads NO keychain and needs NO everyday-browser
1038
+ # session, so it must NEVER be blocked by the keychain preflight.
1039
+ # Auto-import is only possible when some installed browser actually holds
1040
+ # an x.com session. On a fresh or remote/headless box there is nothing to
1041
+ # import, so gating manual login behind the keychain check dead-ended setup
1042
+ # on "keychain locked" even though there was no session to copy anyway
1043
+ # (the exact wedge that stalled the macstadium test box). We now skip
1044
+ # straight to manual login whenever there's nothing to import.
1045
+ #
1046
+ # Source selection:
1047
+ # - explicit --source X -> just that one (one keychain prompt)
1048
+ # - --source all -> the full chrome/arc/brave/edge sweep (legacy)
1049
+ # - no --source (default) -> the browser(s) that ACTUALLY hold an x.com
1050
+ # session, so we prompt the keychain for exactly the right one.
1051
+ if args.source == "all":
1052
+ sources = AUTO_SOURCES
1053
+ has_importable = True
1054
+ elif args.source:
1055
+ sources = [args.source]
1056
+ has_importable = True
1057
+ else:
1058
+ _srcs = _list_sources()
1059
+ _with_session = [s["spec"] for s in _srcs if s.get("x_session")]
1060
+ sources = _with_session or ["chrome:Default"]
1061
+ has_importable = bool(_with_session)
1062
+
1063
+ manual_login = bool(getattr(args, "manual_login", False))
1064
+ # Attempt an auto-import ONLY when the user didn't force manual login AND
1065
+ # there is a real session to copy. Otherwise fall through to manual login.
1066
+ will_import = has_importable and not manual_login
1067
+
1068
+ # 1c. Headless + Keychain pre-flight (#3 + #4, added 2026-06-02) — relevant
1069
+ # ONLY to the import path. copy_browser_cookies.py must read the per-browser
1070
+ # Safe Storage entry from the OS keychain; SSH/launchd-invoked processes get
1071
+ # errSecAuthFailed silently (no prompt). The manual-login path skips this.
1072
+ if will_import and _is_headless():
1073
+ # Probe Chrome (the autoposter default); if that's denied, the rest are too.
1041
1074
  kc_ok, kc_detail = _keychain_safe_storage_ok("Chrome")
1042
1075
  if not kc_ok:
1043
1076
  return {
@@ -1053,28 +1086,17 @@ def cmd_connect(args) -> dict:
1053
1086
  "or another headless context). No GUI prompt is shown for this — macOS "
1054
1087
  "denies access silently. To fix, run this once in the same session:\n"
1055
1088
  " security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
1056
- "Then re-run connect_x. If you're on the autoposter machine via SSH, you "
1057
- "may also need to run it before every fresh shell, or persist with "
1058
- "`security set-keychain-settings -lut 0`."
1089
+ "Then re-run connect_x. Or connect X with manual login instead — that "
1090
+ "signs in directly in the autoposter's own browser and needs no keychain."
1059
1091
  ),
1060
1092
  "remediation_cmd": "security unlock-keychain ~/Library/Keychains/login.keychain-db",
1061
1093
  "cdp": CDP,
1062
1094
  }
1063
1095
 
1064
- # 2. Import from the user's everyday browser.
1065
- # - explicit --source X -> just that one (one keychain prompt)
1066
- # - --source all -> the full chrome/arc/brave/edge sweep (legacy)
1067
- # - no --source (the default) -> auto-pick the browser(s) that ACTUALLY
1068
- # hold an x.com session, so we prompt the keychain for exactly the right
1069
- # one instead of blindly walking all four and prompting per browser.
1070
- if args.source == "all":
1071
- sources = AUTO_SOURCES
1072
- elif args.source:
1073
- sources = [args.source]
1074
- else:
1075
- sources = _auto_pick_sources()
1096
+ # 2. Import from the user's everyday browser (skipped entirely when we're on
1097
+ # the manual-login path — `will_import` False makes this loop a no-op).
1076
1098
  attempts = []
1077
- for src in sources:
1099
+ for src in (sources if will_import else []):
1078
1100
  res = _import_from(src)
1079
1101
  copied = res.get("stdout", "")
1080
1102
  detail = copied or res.get("error") or res.get("stderr")
@@ -1139,8 +1161,15 @@ def cmd_connect(args) -> dict:
1139
1161
  rolled_up_error_type = (
1140
1162
  next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
1141
1163
  )
1142
- manual_login = bool(getattr(args, "manual_login", False))
1143
- open_login = manual_login or rolled_up_error_type == "keychain_acl_denied"
1164
+ # Open a focused login window when the user asked for manual login, when the
1165
+ # keychain prompt was denied (import can't proceed), OR when there was nothing
1166
+ # to import in the first place — in that last case manual login is the ONLY
1167
+ # path that can work, so we must open the window rather than dead-end.
1168
+ open_login = (
1169
+ manual_login
1170
+ or not will_import
1171
+ or rolled_up_error_type == "keychain_acl_denied"
1172
+ )
1144
1173
 
1145
1174
  shown = False
1146
1175
  if open_login:
@@ -1156,7 +1185,7 @@ def cmd_connect(args) -> dict:
1156
1185
  # That race is what made setup misreport the handle as "missing." If the
1157
1186
  # cookie appears, fall through to the same connected/persist/handle path the
1158
1187
  # auto-import success branch uses.
1159
- login_wait = getattr(args, "login_wait", 90.0)
1188
+ login_wait = getattr(args, "login_wait", 300.0)
1160
1189
  if login_wait and login_wait > 0 and _poll_for_login(timeout=login_wait):
1161
1190
  try:
1162
1191
  if _is_session_valid():
@@ -1197,16 +1226,23 @@ def cmd_connect(args) -> dict:
1197
1226
  )
1198
1227
  extra["remediation"] = "rerun_connect_x_and_click_allow"
1199
1228
  elif open_login:
1200
- # Explicit --manual-login: a window is open and we waited; they just
1201
- # haven't finished signing in yet.
1229
+ # Manual login: either the caller asked for it (--manual-login) or there
1230
+ # was no existing X session anywhere to import, so this is the only path.
1231
+ # A focused login window is open and we waited; they just haven't finished
1232
+ # signing in yet.
1233
+ _why = (
1234
+ "" if will_import
1235
+ else "No existing X session was found in your everyday browser to import, so "
1236
+ )
1237
+ _tried = (" (Auto-import tried: " + ", ".join(sources) + ".)") if will_import else ""
1202
1238
  note = (
1203
- "A Chrome window for the autoposter is open at the X login page"
1239
+ _why
1240
+ + "a Chrome window for the autoposter is open at the X login page"
1204
1241
  + ("" if shown else " (if you don't see it, look for a 'Google Chrome' window)")
1205
1242
  + " and you are NOT logged in yet. Log in there yourself — username, password, "
1206
1243
  "and 2FA if prompted — in that window. When your X home timeline shows, ask me "
1207
1244
  "to confirm and I'll re-check (run connect_x again). The session is saved to the "
1208
- "autoposter's own profile, so this is a one-time step. "
1209
- "(Auto-import tried: " + ", ".join(sources) + ".)"
1245
+ "autoposter's own profile, so this is a one-time step." + _tried
1210
1246
  )
1211
1247
  else:
1212
1248
  # Auto-import failed for a non-deny reason and the user did NOT ask for
@@ -1294,10 +1330,12 @@ def main() -> int:
1294
1330
  "the login window only opens when the user DENIED the keychain "
1295
1331
  "prompt; every other auto-import failure returns needs_login "
1296
1332
  "without popping an unexpected browser window.")
1297
- c.add_argument("--login-wait", type=float, default=90.0,
1333
+ c.add_argument("--login-wait", type=float, default=300.0,
1298
1334
  help="Seconds to wait for a MANUAL login to complete before "
1299
- "returning needs_login (default 90; 0 disables the wait). "
1300
- "Prevents the detection race that misreports the handle as missing.")
1335
+ "returning needs_login (default 300; 0 disables the wait). "
1336
+ "A human finding the on-screen/VNC window plus password + 2FA "
1337
+ "routinely exceeds 90s, so the wait is generous. Prevents the "
1338
+ "detection race that misreports the handle as missing.")
1301
1339
  args = ap.parse_args()
1302
1340
 
1303
1341
  if args.cmd == "detect-sources":
@@ -512,6 +512,26 @@ def _is_newer(latest, current) -> bool:
512
512
  return _ver_key(latest) > _ver_key(current)
513
513
 
514
514
 
515
+ def version_status() -> dict:
516
+ """Version/update fields ONLY, independent of the rest of compute(). compute()
517
+ calls _x_status() first, which can block up to 90s in the setup_twitter_auth.py
518
+ subprocess (CDP to Chrome) whenever a scan job is holding the browser busy —
519
+ that freezes update_available/latest_version on an otherwise-busy box even
520
+ though the GitHub check itself is a ~1s curl. Callers that want the "Update
521
+ available" banner to stay live regardless of X/runtime state should poll this
522
+ directly instead of waiting on the full compute()."""
523
+ installed = _resolve_version()
524
+ channel = _channel()
525
+ latest, latest_tag = _latest_published(channel)
526
+ return {
527
+ "version": installed,
528
+ "channel": channel,
529
+ "latest_version": latest,
530
+ "latest_tag": latest_tag,
531
+ "update_available": bool(latest) and _is_newer(latest, installed),
532
+ }
533
+
534
+
515
535
  # ---- onboarding ledger + live overlay --------------------------------------
516
536
  def _onboarding_live(live_status):
517
537
  led = _read_json(os.path.join(_state_dir(), "onboarding-progress.json")) or {}