@m13v/s4l 1.7.7 → 1.7.8-rc.2
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/mcp/dist/index.js +48 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +29 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/browser_lifecycle.py +23 -9
- package/scripts/claude_job.py +191 -2
- package/scripts/draft_provider.py +120 -0
- package/scripts/foreground_event_capture.sh +44 -0
- package/scripts/install.sh +119 -0
- package/scripts/scan_reddit_replies.py +111 -27
- package/scripts/schedule_state.py +16 -0
- package/scripts/setup_reddit_auth.py +62 -36
- package/scripts/setup_twitter_auth.py +121 -42
- package/scripts/virality.py +12 -1
- package/skill/engage-dm-replies.sh +22 -5
|
@@ -734,6 +734,49 @@ def _keychain_safe_storage_ok(browser_label: str = "Chrome") -> tuple[bool, str]
|
|
|
734
734
|
return False, (err_tail[-1] if err_tail else f"exit {r.returncode}")
|
|
735
735
|
|
|
736
736
|
|
|
737
|
+
# --- Keychain prompt-once latch (2026-08-06) --------------------------------
|
|
738
|
+
# Product decision: the user is asked for keychain access AT MOST ONCE, by the
|
|
739
|
+
# import attempt itself (copy_browser_cookies' `security` call blocks on the
|
|
740
|
+
# macOS dialog with no timeout, so the one prompt waits for a human answer).
|
|
741
|
+
# Any Deny/Cancel is recorded here, and every later connect_x skips the import
|
|
742
|
+
# path entirely and goes straight to manual login. Without this latch the setup
|
|
743
|
+
# agent saw "keychain locked, re-run connect_x", retried the import, and
|
|
744
|
+
# re-fired the password prompt at a user who had already said no (install
|
|
745
|
+
# a208aa6b, 2026-08-06: three prompts in five minutes). Lives OUTSIDE the
|
|
746
|
+
# per-platform profile dir so the reddit importer can honor the same decision.
|
|
747
|
+
_KEYCHAIN_DECLINED_LATCH = (
|
|
748
|
+
Path.home() / ".claude" / "browser-profiles" / "keychain-import-declined.json"
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _keychain_declined() -> dict | None:
|
|
753
|
+
"""The recorded Deny/Cancel from a previous import attempt, or None."""
|
|
754
|
+
try:
|
|
755
|
+
return json.loads(_KEYCHAIN_DECLINED_LATCH.read_text())
|
|
756
|
+
except Exception:
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _keychain_mark_declined(source: str, detail: str | None) -> None:
|
|
761
|
+
try:
|
|
762
|
+
_KEYCHAIN_DECLINED_LATCH.parent.mkdir(parents=True, exist_ok=True)
|
|
763
|
+
_KEYCHAIN_DECLINED_LATCH.write_text(json.dumps({
|
|
764
|
+
"declined_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
765
|
+
"source": source,
|
|
766
|
+
"detail": (detail or "")[:500],
|
|
767
|
+
}))
|
|
768
|
+
except Exception as e:
|
|
769
|
+
print(f"setup_twitter_auth: could not write keychain latch ({e})",
|
|
770
|
+
file=sys.stderr)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _keychain_clear_declined() -> None:
|
|
774
|
+
try:
|
|
775
|
+
_KEYCHAIN_DECLINED_LATCH.unlink(missing_ok=True)
|
|
776
|
+
except Exception:
|
|
777
|
+
pass
|
|
778
|
+
|
|
779
|
+
|
|
737
780
|
def _classify_import_error(detail: str | None) -> str:
|
|
738
781
|
"""Map a copy_browser_cookies.py error string to a structured type so the
|
|
739
782
|
upper layers (connect_x, the user) can show a precise remediation instead
|
|
@@ -750,9 +793,13 @@ def _classify_import_error(detail: str | None) -> str:
|
|
|
750
793
|
# - unlock/confirm prompt, click Cancel/Deny -> errSecUserCanceled (-128)
|
|
751
794
|
# Both mean "the user actively refused", and both have the same fix (re-run
|
|
752
795
|
# and click Allow), so collapse them into one type.
|
|
796
|
+
# "exit 128" is `security`'s bare exit code for errSecUserCanceled when
|
|
797
|
+
# stderr is empty (observed install a208aa6b 2026-08-06); without this
|
|
798
|
+
# match a user's Cancel was misread as a locked keychain and retried.
|
|
753
799
|
if (("access denied" in d) or ("errsecauth" in d) or ("-25293" in d)
|
|
754
800
|
or ("user canceled" in d) or ("user cancelled" in d)
|
|
755
|
-
or ("errsecusercanceled" in d) or ("-128" in d)
|
|
801
|
+
or ("errsecusercanceled" in d) or ("-128" in d)
|
|
802
|
+
or ("exit 128" in d)):
|
|
756
803
|
return "keychain_acl_denied"
|
|
757
804
|
if ("not be found in the keychain" in d) or ("errsecitemnotfound" in d):
|
|
758
805
|
return "keychain_entry_missing"
|
|
@@ -1055,33 +1102,37 @@ def cmd_connect(args) -> dict:
|
|
|
1055
1102
|
# there is a real session to copy. Otherwise fall through to manual login.
|
|
1056
1103
|
will_import = has_importable and not manual_login
|
|
1057
1104
|
|
|
1058
|
-
# 1c.
|
|
1059
|
-
#
|
|
1060
|
-
#
|
|
1061
|
-
#
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1105
|
+
# 1c. Keychain prompt-once gate (2026-08-06; replaces the 2026-06-02
|
|
1106
|
+
# headless probe). The old probe pre-read "Chrome Safe Storage" with a
|
|
1107
|
+
# 10s timeout, which (a) fired a SEPARATE SecurityAgent prompt before
|
|
1108
|
+
# the import's own one, (b) auto-dismissed that dialog from under the
|
|
1109
|
+
# user when the timeout killed `security`, and (c) was gated on
|
|
1110
|
+
# _is_headless(), which is True for EVERY MCP-spawned run (stdin is
|
|
1111
|
+
# never a tty), so all desktop installs got told "you're probably on
|
|
1112
|
+
# SSH; run security unlock-keychain and re-run connect_x" — advice the
|
|
1113
|
+
# setup agent obediently looped on, re-prompting a user who had already
|
|
1114
|
+
# clicked Cancel.
|
|
1115
|
+
# Now the import attempt itself is the single keychain prompt: its
|
|
1116
|
+
# `security` call blocks on the dialog with no timeout, its Deny/Cancel
|
|
1117
|
+
# comes back as a classifiable error, and the latch below makes the
|
|
1118
|
+
# answer permanent. A truly headless (SSH/launchd) run fails that same
|
|
1119
|
+
# call fast with "User interaction is not allowed" -> keychain_locked,
|
|
1120
|
+
# which is NOT latched (no prompt was ever shown).
|
|
1121
|
+
if will_import:
|
|
1122
|
+
if getattr(args, "retry_keychain_import", False):
|
|
1123
|
+
_keychain_clear_declined()
|
|
1124
|
+
declined = _keychain_declined()
|
|
1125
|
+
if declined:
|
|
1126
|
+
# The one allowed prompt was already answered with Deny/Cancel.
|
|
1127
|
+
# Skip the import path for good and fall through to manual login
|
|
1128
|
+
# in THIS call (will_import False forces open_login below), so
|
|
1129
|
+
# setup keeps moving instead of asking again.
|
|
1130
|
+
will_import = False
|
|
1131
|
+
keychain_declined = declined
|
|
1132
|
+
else:
|
|
1133
|
+
keychain_declined = None
|
|
1134
|
+
else:
|
|
1135
|
+
keychain_declined = None
|
|
1085
1136
|
|
|
1086
1137
|
# 2. Import from the user's everyday browser (skipped entirely when we're on
|
|
1087
1138
|
# the manual-login path — `will_import` False makes this loop a no-op).
|
|
@@ -1098,6 +1149,13 @@ def cmd_connect(args) -> dict:
|
|
|
1098
1149
|
"detail": detail,
|
|
1099
1150
|
"error_type": error_type,
|
|
1100
1151
|
})
|
|
1152
|
+
if error_type == "keychain_acl_denied":
|
|
1153
|
+
# The user answered the ONE allowed keychain prompt with
|
|
1154
|
+
# Deny/Cancel. Record it forever and stop the sweep — trying the
|
|
1155
|
+
# next source browser would fire another prompt, and once means
|
|
1156
|
+
# once. Every later connect_x skips the import path entirely.
|
|
1157
|
+
_keychain_mark_declined(src, detail)
|
|
1158
|
+
break
|
|
1101
1159
|
if not res.get("ok"):
|
|
1102
1160
|
continue
|
|
1103
1161
|
# 3. Re-validate after this source.
|
|
@@ -1151,6 +1209,9 @@ def cmd_connect(args) -> dict:
|
|
|
1151
1209
|
rolled_up_error_type = (
|
|
1152
1210
|
next(iter(distinct_error_types)) if len(distinct_error_types) == 1 else None
|
|
1153
1211
|
)
|
|
1212
|
+
# A Deny/Cancel on THIS run's prompt, regardless of what other sources
|
|
1213
|
+
# failed with (the single-type roll-up above misses mixed-error runs).
|
|
1214
|
+
denied_now = any(a.get("error_type") == "keychain_acl_denied" for a in attempts)
|
|
1154
1215
|
# Open a focused login window when the user asked for manual login, when the
|
|
1155
1216
|
# keychain prompt was denied (import can't proceed), OR when there was nothing
|
|
1156
1217
|
# to import in the first place — in that last case manual login is the ONLY
|
|
@@ -1158,7 +1219,7 @@ def cmd_connect(args) -> dict:
|
|
|
1158
1219
|
open_login = (
|
|
1159
1220
|
manual_login
|
|
1160
1221
|
or not will_import
|
|
1161
|
-
or
|
|
1222
|
+
or denied_now
|
|
1162
1223
|
)
|
|
1163
1224
|
|
|
1164
1225
|
shown = False
|
|
@@ -1198,23 +1259,36 @@ def cmd_connect(args) -> dict:
|
|
|
1198
1259
|
|
|
1199
1260
|
# Build the needs_login note from the rolled-up cause + whether a window opened.
|
|
1200
1261
|
extra = {}
|
|
1201
|
-
if
|
|
1202
|
-
# The user clicked Deny/Cancel on the keychain prompt.
|
|
1203
|
-
#
|
|
1204
|
-
#
|
|
1205
|
-
#
|
|
1262
|
+
if denied_now:
|
|
1263
|
+
# The user clicked Deny/Cancel on the ONE allowed keychain prompt. That
|
|
1264
|
+
# answer is final (latched above): do NOT tell the caller to re-run the
|
|
1265
|
+
# import — that advice made setup agents loop and re-prompt. Manual
|
|
1266
|
+
# login is the path now, and a login window is already open for it.
|
|
1206
1267
|
note = (
|
|
1207
|
-
"
|
|
1208
|
-
"
|
|
1209
|
-
"
|
|
1210
|
-
"Always Allow) on that prompt and the import will finish on its own. "
|
|
1211
|
-
"If you'd rather not grant keychain access, there's already a Chrome window open "
|
|
1212
|
-
"at the X login page"
|
|
1268
|
+
"You declined the macOS Keychain prompt, so automatic import is off — "
|
|
1269
|
+
"we only ask once and won't show that prompt again. "
|
|
1270
|
+
"A Chrome window is open at the X login page"
|
|
1213
1271
|
+ ("" if shown else " (look for a 'Google Chrome' window)")
|
|
1214
|
-
+ " —
|
|
1272
|
+
+ " — log in there by hand (username, password, 2FA if asked) and "
|
|
1273
|
+
"ask me to re-check. Do NOT re-run the cookie import. "
|
|
1215
1274
|
"(Auto-import tried: " + ", ".join(sources) + ".)"
|
|
1216
1275
|
)
|
|
1217
|
-
extra["remediation"] = "
|
|
1276
|
+
extra["remediation"] = "use_manual_login"
|
|
1277
|
+
extra["keychain_prompt"] = "declined_latched"
|
|
1278
|
+
elif keychain_declined:
|
|
1279
|
+
# A PREVIOUS run already recorded the Deny/Cancel; this call skipped the
|
|
1280
|
+
# import path entirely (no prompt was shown) and went straight to the
|
|
1281
|
+
# manual-login window.
|
|
1282
|
+
note = (
|
|
1283
|
+
"You previously declined the macOS Keychain prompt, so I skipped the "
|
|
1284
|
+
"automatic import (we only ask once). A Chrome window is open at the "
|
|
1285
|
+
"X login page"
|
|
1286
|
+
+ ("" if shown else " (look for a 'Google Chrome' window)")
|
|
1287
|
+
+ " — log in there by hand and ask me to re-check. Do NOT re-run "
|
|
1288
|
+
"the cookie import; it will not prompt again."
|
|
1289
|
+
)
|
|
1290
|
+
extra["remediation"] = "use_manual_login"
|
|
1291
|
+
extra["keychain_prompt"] = "declined_latched"
|
|
1218
1292
|
elif open_login:
|
|
1219
1293
|
# Manual login: either the caller asked for it (--manual-login) or there
|
|
1220
1294
|
# was no existing X session anywhere to import, so this is the only path.
|
|
@@ -1291,6 +1365,11 @@ def main() -> int:
|
|
|
1291
1365
|
"A human finding the on-screen/VNC window plus password + 2FA "
|
|
1292
1366
|
"routinely exceeds 90s, so the wait is generous. Prevents the "
|
|
1293
1367
|
"detection race that misreports the handle as missing.")
|
|
1368
|
+
c.add_argument("--retry-keychain-import", action="store_true",
|
|
1369
|
+
help="Clear the keychain-declined latch so the import path may "
|
|
1370
|
+
"show the macOS keychain prompt ONE more time. The prompt "
|
|
1371
|
+
"is once-only by policy; pass this only when the user "
|
|
1372
|
+
"explicitly asks to retry the automatic import.")
|
|
1294
1373
|
args = ap.parse_args()
|
|
1295
1374
|
|
|
1296
1375
|
if args.cmd == "detect-sources":
|
package/scripts/virality.py
CHANGED
|
@@ -86,7 +86,18 @@ def calculate_reddit_virality_score(thread):
|
|
|
86
86
|
# S4L_REDDIT_VIRALITY_*) beat these; explicit args beat env.
|
|
87
87
|
_BAR_DEFAULTS = {
|
|
88
88
|
"twitter": {"pctile": 0.99, "min_sample": 200, "hours": 24},
|
|
89
|
-
|
|
89
|
+
# Reddit calibration (2026-08-05): discovery volume per install is ~100x
|
|
90
|
+
# smaller than X (~60 scored/day vs ~9k), so X's 200-in-24h min-sample
|
|
91
|
+
# left the bar disarmed on all but spike days (observed: armed only
|
|
92
|
+
# Jul 30-31, ungated 139-post day Aug 2). A 72h window (fine for threads
|
|
93
|
+
# that stay actionable ~7 days) with min_sample 50 keeps the bar armed at
|
|
94
|
+
# normal volume while still failing open on genuinely fresh installs.
|
|
95
|
+
# These platform defaults are the SINGLE source of truth for gate
|
|
96
|
+
# calibration; the S4L_<PLATFORM>_VIRALITY_* env overrides are debug-only
|
|
97
|
+
# and must not be set in plists or shipped configs. (The percentile is
|
|
98
|
+
# governed server-side by installations.posting_mode regardless of the
|
|
99
|
+
# pctile requested here.)
|
|
100
|
+
"reddit": {"pctile": 0.99, "min_sample": 50, "hours": 72},
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
|
|
@@ -171,6 +171,19 @@ PENDING_PLAT_ARG=""
|
|
|
171
171
|
[ -n "$PLATFORM" ] && PENDING_PLAT_ARG="--platform $PLATFORM"
|
|
172
172
|
PENDING_CONVOS=$(python3 "$REPO_DIR/scripts/dm_engage_helper.py" pending $PENDING_PLAT_ARG --limit 30 2>/dev/null || echo "null")
|
|
173
173
|
|
|
174
|
+
# Phase D scope guard (2026-08-05). The prompt used to tell the model to re-query
|
|
175
|
+
# the queue itself with `dm_conversation.py pending`, which has NO platform
|
|
176
|
+
# filter. A platform-scoped run (reddit fires at :13/:43) would pick up another
|
|
177
|
+
# platform's needs_reply rows in the 1-3 min window before the owning lane
|
|
178
|
+
# (twitter fires at :14) logged its outbound, and both would send: the recipient
|
|
179
|
+
# got two near-duplicate replies and the loser's log-outbound was DEDUP BLOCKED,
|
|
180
|
+
# so the duplicate never appeared in dm_messages (38 duplicate pairs May-Aug
|
|
181
|
+
# 2026, first reported by HireFireTeam on 2026-07-09). Phase D now re-queries
|
|
182
|
+
# through the server-side-scoped helper, and this note pins the boundary inside
|
|
183
|
+
# the prompt itself.
|
|
184
|
+
PHASE_D_SCOPE_NOTE=""
|
|
185
|
+
[ -n "$PLATFORM" ] && PHASE_D_SCOPE_NOTE="HARD PLATFORM BOUNDARY: this run handles platform '$PLATFORM' ONLY. The pending query above is already scoped to it. If a row or query result for any other platform surfaces anyway, SKIP it silently and make no DB change to it: another lane owns that row, and replying from this run sends the recipient a duplicate message."
|
|
186
|
+
|
|
174
187
|
if [ "$PENDING_CONVOS" = "null" ] || [ -z "$PENDING_CONVOS" ]; then
|
|
175
188
|
log "No conversations needing replies. Checking platforms for new inbound messages..."
|
|
176
189
|
else
|
|
@@ -454,10 +467,12 @@ counting (event_id dedup will block it but don't even try).
|
|
|
454
467
|
2. Find Reddit conversations needing a reply (includes both newly-ingested
|
|
455
468
|
chat rooms and any legacy PMs logged via step 1):
|
|
456
469
|
```bash
|
|
457
|
-
cd ~/social-autoposter && python3 scripts/
|
|
470
|
+
cd ~/social-autoposter && python3 scripts/dm_engage_helper.py pending --platform reddit --limit 30
|
|
458
471
|
```
|
|
459
|
-
|
|
460
|
-
|
|
472
|
+
This list is server-side scoped to Reddit; rows for other platforms never
|
|
473
|
+
belong to this phase (do NOT use the cross-platform `dm_conversation.py
|
|
474
|
+
pending` here). This is the authoritative list; don't reconstruct it from
|
|
475
|
+
sidebar scrapes.
|
|
461
476
|
|
|
462
477
|
3. For each Reddit PM/comment-reply surfaced by step 1 that isn't already in
|
|
463
478
|
dms, create a row and log the inbound (chat rooms already have rows from
|
|
@@ -660,10 +675,12 @@ $PHASE_C_BLOCK
|
|
|
660
675
|
|
|
661
676
|
## PHASE D: Reply to all conversations with pending inbound messages
|
|
662
677
|
|
|
663
|
-
After scanning, query for
|
|
678
|
+
After scanning, query for conversations needing replies (server-side scoped to this run's platform):
|
|
664
679
|
\`\`\`bash
|
|
665
|
-
cd ~/social-autoposter && python3 scripts/
|
|
680
|
+
cd ~/social-autoposter && python3 scripts/dm_engage_helper.py pending $PENDING_PLAT_ARG --limit 30
|
|
666
681
|
\`\`\`
|
|
682
|
+
Do NOT use \`dm_conversation.py pending\` or hit /api/v1/dms/pending directly: both are cross-platform and will show you rows that belong to other lanes.
|
|
683
|
+
$PHASE_D_SCOPE_NOTE
|
|
667
684
|
|
|
668
685
|
Known conversations from the database that already need replies:
|
|
669
686
|
$PENDING_CONVOS
|