@m13v/s4l 1.7.4-rc.4 → 1.7.4

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 (86) hide show
  1. package/README.md +134 -101
  2. package/bin/cli.js +23 -0
  3. package/mcp/dist/index.js +24 -5
  4. package/mcp/dist/runtime.js +110 -0
  5. package/mcp/dist/version.js +163 -54
  6. package/mcp/dist/version.json +2 -2
  7. package/mcp/manifest.json +1 -1
  8. package/mcp/menubar/s4l_card.py +55 -19
  9. package/mcp/menubar/s4l_menubar.py +179 -56
  10. package/mcp/menubar/s4l_state.py +41 -1
  11. package/mcp/package.json +1 -1
  12. package/package.json +4 -2
  13. package/scripts/active_experiments.py +30 -6
  14. package/scripts/autopilot_stall_watch.py +185 -63
  15. package/scripts/cdp_ready_check.py +94 -0
  16. package/scripts/engage_twitter_helper.py +83 -17
  17. package/scripts/engagement_styles.py +221 -26
  18. package/scripts/enrich_reply_parents.py +68 -22
  19. package/scripts/feedback_digest.py +24 -1
  20. package/scripts/generate_daily_human_style.py +31 -7
  21. package/scripts/invent_styles.py +103 -26
  22. package/scripts/linkedin_cadence.py +234 -0
  23. package/scripts/linkedin_killswitch.py +43 -0
  24. package/scripts/memory_snapshot.py +57 -0
  25. package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
  26. package/scripts/recent_self_posts.py +6 -0
  27. package/scripts/reddit_browser.py +57 -3
  28. package/scripts/s4l_box_update.sh +20 -1
  29. package/scripts/s4l_mode.py +3 -4
  30. package/scripts/scan_x_profile.py +25 -7
  31. package/scripts/schedule_state.py +23 -0
  32. package/scripts/scheduled_task_selfheal.py +213 -16
  33. package/scripts/setup_twitter_auth.py +6 -1
  34. package/scripts/snapshot.py +179 -61
  35. package/scripts/stall_guard.py +70 -0
  36. package/scripts/test_scheduled_task_selfheal.py +121 -0
  37. package/scripts/top_performers.py +34 -0
  38. package/scripts/twitter_browser.py +146 -8
  39. package/scripts/twitter_post_plan.py +10 -1
  40. package/scripts/twitter_scan.py +26 -0
  41. package/scripts/watchdog_hung_runs.py +117 -3
  42. package/skill/amplitude-24h-signups.sh +1 -1
  43. package/skill/audit-dm-staleness.sh +1 -1
  44. package/skill/audit-linkedin.sh +1 -1
  45. package/skill/audit-reddit-resurrect.sh +1 -1
  46. package/skill/audit.sh +5 -5
  47. package/skill/check-web-chats.sh +1 -1
  48. package/skill/dm-outreach-linkedin.sh +4 -4
  49. package/skill/dm-outreach-reddit.sh +1 -1
  50. package/skill/dm-outreach-twitter.sh +1 -1
  51. package/skill/engage-dm-replies.sh +16 -3
  52. package/skill/engage-linkedin.sh +2 -2
  53. package/skill/engage-moltbook.sh +1 -1
  54. package/skill/engage-twitter.sh +4 -4
  55. package/skill/github-engage.sh +1 -1
  56. package/skill/lib/browser-launch.sh +54 -0
  57. package/skill/lib/linkedin-backend.sh +22 -15
  58. package/skill/lib/reddit-backend.sh +17 -11
  59. package/skill/lib/twitter-backend.sh +137 -16
  60. package/skill/link-edit-github.sh +1 -1
  61. package/skill/link-edit-moltbook.sh +1 -1
  62. package/skill/link-edit-reddit.sh +1 -1
  63. package/skill/linkedin-cadence.sh +20 -0
  64. package/skill/linkedin-presence.sh +1 -1
  65. package/skill/lock.sh +30 -6
  66. package/skill/precompute-stats.sh +1 -1
  67. package/skill/refresh-instagram-tokens.sh +1 -1
  68. package/skill/refresh-twitter-following.sh +1 -1
  69. package/skill/run-generate-daily-style.sh +1 -1
  70. package/skill/run-instagram-daily.sh +1 -1
  71. package/skill/run-instagram-render.sh +1 -1
  72. package/skill/run-linkedin-launchd.sh +1 -1
  73. package/skill/run-linkedin-unipile.sh +1 -1
  74. package/skill/run-linkedin.sh +1 -1
  75. package/skill/run-reddit-search.sh +2 -2
  76. package/skill/run-reddit-threads.sh +1 -1
  77. package/skill/run-twitter-cycle.sh +190 -55
  78. package/skill/scan-instagram-replies.sh +1 -1
  79. package/skill/sentry-digest.sh +1 -1
  80. package/skill/stats-instagram.sh +1 -1
  81. package/skill/stats-linkedin.sh +2 -2
  82. package/skill/stats.sh +1 -1
  83. package/skill/sweep-link-clicks.sh +1 -1
  84. package/scripts/_lock_preempt_test.py +0 -60
  85. package/scripts/saps_activity.py +0 -18
  86. package/scripts/saps_mode.py +0 -18
@@ -17,7 +17,7 @@ set -euo pipefail
17
17
  # State: ~/.claude/social-autoposter/linkedin.killswitch
18
18
  # Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
19
19
  if [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
20
- echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
20
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
21
21
  echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
22
22
  exit 0
23
23
  fi
@@ -84,7 +84,7 @@ LOG_FILE="$LOG_DIR/engage-linkedin-$(date +%Y-%m-%d_%H%M%S).log"
84
84
  BATCH_ID="enli-$(date +%Y%m%d_%H%M%S)-$$"
85
85
  export SA_CYCLE_ID="$BATCH_ID"
86
86
 
87
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
87
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
88
88
 
89
89
  RUN_START=$(date +%s)
90
90
  log "=== LinkedIn Engagement Run: $(date) ==="
@@ -23,7 +23,7 @@ LOG_DIR="$REPO_DIR/skill/logs"
23
23
  mkdir -p "$LOG_DIR"
24
24
  LOG_FILE="$LOG_DIR/engage-moltbook-$(date +%Y-%m-%d_%H%M%S).log"
25
25
 
26
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
26
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
27
27
 
28
28
  RUN_START=$(date +%s)
29
29
  log "=== MoltBook Engage Run: $(date) ==="
@@ -25,12 +25,12 @@ source "$(dirname "$0")/lock.sh"
25
25
  # Sets MCP_CONFIG_FILE, BROWSER_INSTRUCTIONS, exports TWITTER_CDP_URL=9555.
26
26
  source "$(dirname "$0")/lib/twitter-backend.sh"
27
27
 
28
- echo "[$(date +%H:%M:%S)] Acquiring twitter-browser lock (pid=$$)..." | tee -a "$LOG_FILE"
28
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Acquiring twitter-browser lock (pid=$$)..." | tee -a "$LOG_FILE"
29
29
  acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
30
- echo "[$(date +%H:%M:%S)] twitter-browser lock held (pid=$$)" | tee -a "$LOG_FILE"
30
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] twitter-browser lock held (pid=$$)" | tee -a "$LOG_FILE"
31
31
  # Probe + launch harness Chrome on port 9555 if needed, then sweep leftover tabs.
32
32
  ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
33
- echo "[$(date +%H:%M:%S)] Acquiring twitter (pipeline) lock (pid=$$)..." | tee -a "$LOG_FILE"
33
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Acquiring twitter (pipeline) lock (pid=$$)..." | tee -a "$LOG_FILE"
34
34
  acquire_lock "twitter" 3600 2>>"$LOG_FILE"
35
35
 
36
36
  # Load secrets
@@ -47,7 +47,7 @@ BATCH_SIZE=500
47
47
  ENGAGE_TWITTER_HELPER="$REPO_DIR/scripts/engage_twitter_helper.py"
48
48
  # (LOG_DIR/LOG_FILE bootstrapped at top of script.)
49
49
 
50
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
50
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
51
51
 
52
52
  RUN_START=$(date +%s)
53
53
  log "=== Twitter Engagement Run: $(date) ==="
@@ -20,7 +20,7 @@ LOG_DIR="$REPO_DIR/skill/logs"
20
20
  mkdir -p "$LOG_DIR"
21
21
  LOG_FILE="$LOG_DIR/github-engage-$(date +%Y-%m-%d_%H%M%S).log"
22
22
 
23
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
23
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
24
24
 
25
25
  # Per-cycle batch id stamped onto every claude_sessions row spawned by this
26
26
  # engagement run (via SA_CYCLE_ID env -> log_claude_session.py). 2026-05-10
@@ -0,0 +1,54 @@
1
+ #!/bin/bash
2
+ # browser-launch.sh — the ONE way any backend spawns a harness Chrome.
3
+ #
4
+ # Consolidates what twitter-backend.sh and linkedin-backend.sh grew as
5
+ # hand-rolled near-duplicates on 2026-07-13 (and reddit-backend.sh never got
6
+ # at all). Source this from a backend lib and call:
7
+ #
8
+ # launch_harness_chrome "$_chrome_bin" "$_prof_dir" <chrome arg>...
9
+ #
10
+ # What every launch gets, uniformly:
11
+ # 1. CLEAN-EXIT STAMP: mark the profile's last exit as clean first, so a
12
+ # SIGKILLed Chrome (wedge heal, stall abort) doesn't session-restore a
13
+ # crashed "Aw, Snap" corpse tab on relaunch.
14
+ # 2. NO FOCUS STEAL (macOS + .app bundle): `open -n -g` launches a NEW
15
+ # instance (-n: the user's personal Chrome may already be running;
16
+ # without it LaunchServices pokes that instance and drops our --args)
17
+ # without activation (-g). A directly-exec'd Chrome always activates
18
+ # itself and planted a window over the user's work on every relaunch.
19
+ # LaunchServices also parents the process outside the caller's launchd
20
+ # job process group — independent cover for the pgroup-reaping bug.
21
+ # (Tested alternatives that DON'T work: `-j` — Chrome unhides and
22
+ # activates itself anyway; `--no-startup-window` — the activation just
23
+ # moves to first-tab creation.)
24
+ # 3. DETACHED FALLBACK (Linux / bare chromium binaries): direct exec in a
25
+ # NEW SESSION via os.setsid, so a transient launchd job's exit can't
26
+ # SIGKILL Chrome with the job's process group (2026-07-12 root cause of
27
+ # the kill-relaunch-foreground loop).
28
+ launch_harness_chrome() {
29
+ local _bl_chrome_bin="$1"; shift
30
+ local _bl_prof_dir="$1"; shift
31
+ "${S4L_PYTHON:-python3}" -c 'import json, os, sys
32
+ p = os.path.join(sys.argv[1], "Default", "Preferences")
33
+ try:
34
+ d = json.load(open(p))
35
+ except Exception:
36
+ raise SystemExit(0)
37
+ prof = d.setdefault("profile", {})
38
+ prof["exit_type"] = "Normal"
39
+ prof["exited_cleanly"] = True
40
+ json.dump(d, open(p, "w"))' "$_bl_prof_dir" 2>/dev/null || true
41
+ local _bl_app_bundle=""
42
+ case "$_bl_chrome_bin" in
43
+ *.app/Contents/MacOS/*) _bl_app_bundle="${_bl_chrome_bin%%/Contents/MacOS/*}" ;;
44
+ esac
45
+ if [ "$(uname -s)" = "Darwin" ] && [ -n "$_bl_app_bundle" ] && [ -d "$_bl_app_bundle" ]; then
46
+ open -n -g -a "$_bl_app_bundle" --args "$@" >/dev/null 2>&1 || true
47
+ else
48
+ "${S4L_PYTHON:-python3}" -c 'import os,sys
49
+ os.setsid()
50
+ os.execv(sys.argv[1], sys.argv[1:])' \
51
+ "$_bl_chrome_bin" "$@" >/dev/null 2>&1 &
52
+ disown
53
+ fi
54
+ }
@@ -154,7 +154,7 @@ cleanup_harness_tabs() {
154
154
  if ! $_probe 2>/dev/null; then
155
155
  sleep 1
156
156
  if ! $_probe 2>/dev/null; then
157
- echo "[$(date +%H:%M:%S)] cleanup_harness_tabs: SKIPPED (linkedin-harness CDP /json/version unreachable after 10s+retry)" >&2
157
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] cleanup_harness_tabs: SKIPPED (linkedin-harness CDP /json/version unreachable after 10s+retry)" >&2
158
158
  return 0
159
159
  fi
160
160
  fi
@@ -221,7 +221,7 @@ _acquire_linkedin_pipeline_lock() {
221
221
  echo "$$" > "$_LI_PIPELINE_LOCK_DIR/pid"
222
222
  echo "$_who" > "$_LI_PIPELINE_LOCK_DIR/holder"
223
223
  export _LI_PIPELINE_LOCK_HELD=1
224
- echo "[$(date +%H:%M:%S)] linkedin-pipeline lock ACQUIRED by $_who (pid $$)" >&2
224
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] linkedin-pipeline lock ACQUIRED by $_who (pid $$)" >&2
225
225
  return 0
226
226
  fi
227
227
  local _h_pid _h_who
@@ -236,11 +236,11 @@ _acquire_linkedin_pipeline_lock() {
236
236
  return 0
237
237
  fi
238
238
  if [ -z "$_h_pid" ] || ! kill -0 "$_h_pid" 2>/dev/null; then
239
- echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: reclaiming stale lock (dead holder ${_h_who} pid ${_h_pid:-unknown})" >&2
239
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] linkedin-pipeline lock: reclaiming stale lock (dead holder ${_h_who} pid ${_h_pid:-unknown})" >&2
240
240
  rm -rf "$_LI_PIPELINE_LOCK_DIR"
241
241
  continue
242
242
  fi
243
- echo "[$(date +%H:%M:%S)] linkedin-pipeline lock: held by ${_h_who} (pid ${_h_pid}); ${_who} skipping this fire (rc=78) to avoid two drivers on the 9556 Chrome" >&2
243
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] linkedin-pipeline lock: held by ${_h_who} (pid ${_h_pid}); ${_who} skipping this fire (rc=78) to avoid two drivers on the 9556 Chrome" >&2
244
244
  return 78
245
245
  done
246
246
  }
@@ -271,10 +271,10 @@ ensure_linkedin_browser_for_backend() {
271
271
  if [ "${LINKEDIN_CDP_URL:-$_BH_LINKEDIN_DEFAULT_URL}" != "$_BH_LINKEDIN_DEFAULT_URL" ]; then
272
272
  local _ext_url="${LINKEDIN_CDP_URL}"
273
273
  if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
274
- echo "[$(date +%H:%M:%S)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
274
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
275
275
  return 0
276
276
  fi
277
- echo "[$(date +%H:%M:%S)] ERROR: LINKEDIN_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host." >&2
277
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: LINKEDIN_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host." >&2
278
278
  return 1
279
279
  fi
280
280
  # Cross-pipeline whole-run lock: only one LinkedIn browser pipeline drives
@@ -288,11 +288,14 @@ ensure_linkedin_browser_for_backend() {
288
288
  _acquire_linkedin_pipeline_lock || return $?
289
289
  # Probe + launch harness Chrome on port 9556 if needed.
290
290
  if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
291
- echo "[$(date +%H:%M:%S)] LinkedIn harness Chrome down on port 9556, launching..." >&2
291
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LinkedIn harness Chrome down on port 9556, launching..." >&2
292
+ # Dated relaunch stamp for central observability (see twitter-backend.sh).
293
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) port=9556" \
294
+ >> "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/logs/chrome-relaunch-events.log" 2>/dev/null || true
292
295
  local _chrome_bin
293
296
  _chrome_bin=$(_resolve_chrome_bin)
294
297
  if [ -z "$_chrome_bin" ]; then
295
- echo "[$(date +%H:%M:%S)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
298
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
296
299
  return 1
297
300
  fi
298
301
  # On Linux + no display, run headless. On root, add --no-sandbox.
@@ -320,7 +323,7 @@ ensure_linkedin_browser_for_backend() {
320
323
  local _stale_pids
321
324
  _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
322
325
  if [ -n "$_stale_pids" ] && ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
323
- echo "[$(date +%H:%M:%S)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
326
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
324
327
  kill $_stale_pids 2>/dev/null || true
325
328
  sleep 2
326
329
  _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
@@ -330,7 +333,12 @@ ensure_linkedin_browser_for_backend() {
330
333
  # The occlusion/backgrounding flags matter: the window sits offscreen,
331
334
  # and without them Chrome stops laying out SPA-rendered content, so
332
335
  # every element measures 0x0 and clicks become impossible (2026-07-03).
333
- "$_chrome_bin" \
336
+ # Spawn via the SHARED launcher (skill/lib/browser-launch.sh) — see
337
+ # that file for the clean-exit stamp + no-focus-steal + detach
338
+ # rationale. Do NOT hand-roll launch logic per backend.
339
+ # shellcheck disable=SC1091
340
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/browser-launch.sh"
341
+ launch_harness_chrome "$_chrome_bin" "$_prof_dir" \
334
342
  --remote-debugging-port=9556 \
335
343
  --user-data-dir="$HOME/.claude/browser-profiles/browser-harness-linkedin" \
336
344
  --no-first-run --no-default-browser-check \
@@ -339,17 +347,16 @@ ensure_linkedin_browser_for_backend() {
339
347
  --disable-renderer-backgrounding \
340
348
  --disable-background-timer-throttling \
341
349
  "${_extra[@]}" \
342
- about:blank >/dev/null 2>&1 &
343
- disown
350
+ about:blank
344
351
  for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
345
352
  curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null && break
346
353
  sleep 1
347
354
  done
348
355
  if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9556/json/version 2>/dev/null; then
349
- echo "[$(date +%H:%M:%S)] ERROR: LinkedIn harness Chrome failed to start within 12s" >&2
356
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: LinkedIn harness Chrome failed to start within 12s" >&2
350
357
  return 1
351
358
  fi
352
- echo "[$(date +%H:%M:%S)] LinkedIn harness Chrome up on port 9556" >&2
359
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] LinkedIn harness Chrome up on port 9556" >&2
353
360
  fi
354
361
  # Always close leftover tabs from prior runs. Safe under acquire_lock
355
362
  # "linkedin-browser" serialization.
@@ -384,7 +391,7 @@ _linkedin_session_detect_gate() {
384
391
  "$_py" "$HOME/social-autoposter/scripts/linkedin_killswitch.py" detect-gate \
385
392
  --cdp-url "${LINKEDIN_CDP_URL:-$_BH_LINKEDIN_DEFAULT_URL}" >&2 || _rc=$?
386
393
  if [ "$_rc" = "2" ]; then
387
- echo "[$(date +%H:%M:%S)] detect-gate tripped the LinkedIn killswitch; aborting this fire" >&2
394
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] detect-gate tripped the LinkedIn killswitch; aborting this fire" >&2
388
395
  return 1
389
396
  fi
390
397
  return 0
@@ -123,7 +123,7 @@ cleanup_harness_tabs() {
123
123
  if ! $_probe 2>/dev/null; then
124
124
  sleep 1
125
125
  if ! $_probe 2>/dev/null; then
126
- echo "[$(date +%H:%M:%S)] cleanup_harness_tabs: SKIPPED (reddit-harness CDP /json/version unreachable after 10s+retry)" >&2
126
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] cleanup_harness_tabs: SKIPPED (reddit-harness CDP /json/version unreachable after 10s+retry)" >&2
127
127
  return 0
128
128
  fi
129
129
  fi
@@ -156,19 +156,19 @@ ensure_reddit_browser_for_backend() {
156
156
  if [ "${REDDIT_CDP_URL:-$_BH_REDDIT_DEFAULT_URL}" != "$_BH_REDDIT_DEFAULT_URL" ]; then
157
157
  local _ext_url="${REDDIT_CDP_URL}"
158
158
  if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
159
- echo "[$(date +%H:%M:%S)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
159
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
160
160
  return 0
161
161
  fi
162
- echo "[$(date +%H:%M:%S)] ERROR: REDDIT_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host." >&2
162
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: REDDIT_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host." >&2
163
163
  return 1
164
164
  fi
165
165
  # Probe + launch harness Chrome on port 9557 if needed.
166
166
  if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
167
- echo "[$(date +%H:%M:%S)] Reddit harness Chrome down on port 9557, launching..." >&2
167
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Reddit harness Chrome down on port 9557, launching..." >&2
168
168
  local _chrome_bin
169
169
  _chrome_bin=$(_resolve_chrome_bin)
170
170
  if [ -z "$_chrome_bin" ]; then
171
- echo "[$(date +%H:%M:%S)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
171
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
172
172
  return 1
173
173
  fi
174
174
  # On Linux + no display, run headless. On root, add --no-sandbox.
@@ -196,30 +196,36 @@ ensure_reddit_browser_for_backend() {
196
196
  local _stale_pids
197
197
  _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
198
198
  if [ -n "$_stale_pids" ] && ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
199
- echo "[$(date +%H:%M:%S)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
199
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
200
200
  kill $_stale_pids 2>/dev/null || true
201
201
  sleep 2
202
202
  _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
203
203
  [ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
204
204
  rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
205
205
  fi
206
- "$_chrome_bin" \
206
+ # Spawn via the SHARED launcher (skill/lib/browser-launch.sh): clean-
207
+ # exit stamp (no Aw-Snap restore), `open -n -g` on macOS (no focus
208
+ # steal, out of this job's pgroup), setsid exec fallback elsewhere.
209
+ # This backend previously used a bare exec and had NONE of those
210
+ # protections (2026-07-13 consolidation).
211
+ # shellcheck disable=SC1091
212
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/browser-launch.sh"
213
+ launch_harness_chrome "$_chrome_bin" "$_prof_dir" \
207
214
  --remote-debugging-port=9557 \
208
215
  --user-data-dir="$HOME/.claude/browser-profiles/reddit-harness" \
209
216
  --no-first-run --no-default-browser-check \
210
217
  --disable-features=ChromeWhatsNewUI \
211
218
  "${_extra[@]}" \
212
- about:blank >/dev/null 2>&1 &
213
- disown
219
+ about:blank
214
220
  for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
215
221
  curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null && break
216
222
  sleep 1
217
223
  done
218
224
  if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9557/json/version 2>/dev/null; then
219
- echo "[$(date +%H:%M:%S)] ERROR: Reddit harness Chrome failed to start within 12s" >&2
225
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: Reddit harness Chrome failed to start within 12s" >&2
220
226
  return 1
221
227
  fi
222
- echo "[$(date +%H:%M:%S)] Reddit harness Chrome up on port 9557" >&2
228
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Reddit harness Chrome up on port 9557" >&2
223
229
  fi
224
230
  # Always close leftover tabs from prior runs. Safe under acquire_lock
225
231
  # "reddit-browser" serialization.
@@ -31,6 +31,14 @@
31
31
 
32
32
  MCP_CONFIG_FILE="$HOME/.claude/browser-agent-configs/twitter-harness-mcp.json"
33
33
 
34
+ # Repo root for the helper scripts this file shells out to, resolved from this
35
+ # file's own location (skill/lib/ -> two up), honoring S4L_REPO_DIR when the
36
+ # caller sets it. The old $HOME/social-autoposter hardcodes ran code OUTSIDE
37
+ # the managed package on customer boxes (unreachable by auto-update) and
38
+ # silently no-op'd where that directory didn't exist, so session restore and
39
+ # tab cleanup never actually ran on such installs (S4L-4H triage 2026-07-12).
40
+ _BH_REPO_DIR="${S4L_REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
41
+
34
42
  # Per-host env override (written by bin/cli.js when installing on an AppMaker
35
43
  # VM, where the canonical browser is Chromium on port 9222 behind the SOAX
36
44
  # residential proxy at 127.0.0.1:3003, NOT the harness Chrome on 9555). On a
@@ -130,11 +138,34 @@ cleanup_harness_tabs() {
130
138
  if ! $_probe 2>/dev/null; then
131
139
  sleep 1
132
140
  if ! $_probe 2>/dev/null; then
133
- echo "[$(date +%H:%M:%S)] cleanup_harness_tabs: SKIPPED (harness CDP /json/version unreachable after 10s+retry)" >&2
141
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] cleanup_harness_tabs: SKIPPED (harness CDP /json/version unreachable after 10s+retry)" >&2
134
142
  return 0
135
143
  fi
136
144
  fi
137
- python3 "$HOME/social-autoposter/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
145
+ python3 "$_BH_REPO_DIR/scripts/cleanup_harness_tabs.py" 2>/dev/null || true
146
+ }
147
+
148
+ _bh_cdp_ready() {
149
+ # Real CDP readiness: complete an actual connect_over_cdp handshake against
150
+ # the harness. /json/version alone is a liveness probe that a WEDGED Chrome
151
+ # still passes (S4L-4H); see scripts/cdp_ready_check.py. Prints the probe's
152
+ # one-line JSON verdict on stdout; exit status is the verdict.
153
+ "${S4L_PYTHON:-python3}" "$_BH_REPO_DIR/scripts/cdp_ready_check.py" \
154
+ "${1:-$_BH_DEFAULT_URL}" 8000 2>/dev/null
155
+ }
156
+
157
+ _bh_record_cdp_health() {
158
+ # Persist the latest readiness verdict where memory_snapshot.py picks it up
159
+ # (cdp_health block on the per-minute heartbeat sample), so a wedge and its
160
+ # restart are visible centrally in installation_resource_samples without
161
+ # SSHing the box. $1 = action tag, $2 = the probe's JSON verdict (may be
162
+ # empty). Best effort, never fails the caller.
163
+ printf '{"ts":"%s","url":"%s","action":"%s","verdict":%s}\n' \
164
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
165
+ "${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" \
166
+ "$1" \
167
+ "${2:-null}" \
168
+ > "$_BH_REPO_DIR/skill/logs/cdp-health.json" 2>/dev/null || true
138
169
  }
139
170
 
140
171
  _resolve_chrome_bin() {
@@ -165,25 +196,96 @@ ensure_twitter_browser_for_backend() {
165
196
  if [ "${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" != "$_BH_DEFAULT_URL" ]; then
166
197
  local _ext_url="${TWITTER_CDP_URL}"
167
198
  if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
168
- echo "[$(date +%H:%M:%S)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
199
+ # HTTP answers; verify the CDP handshake actually completes before
200
+ # handing this browser to the pipeline. We do NOT restart an
201
+ # externally-managed Chrome; fail fast so the cycle doesn't burn
202
+ # 180s per downstream attach against a wedged browser.
203
+ local _ext_verdict
204
+ if ! _ext_verdict=$(_bh_cdp_ready "$_ext_url"); then
205
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: external Chrome at ${_ext_url} is WEDGED (/json/version answers but the CDP handshake never completes). Host must restart it (AppMaker /opt/startup.sh, etc)." >&2
206
+ echo "twitter_cdp_wedge: detected url=${_ext_url} action=none-external" >&2
207
+ _bh_record_cdp_health external_wedged "$_ext_verdict"
208
+ return 1
209
+ fi
210
+ _bh_record_cdp_health ok-external "$_ext_verdict"
211
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
169
212
  # Restore the Twitter login if the sandbox was substituted. AppMaker
170
213
  # Hobby-tier sandboxes have a 1h TTL; on substitution /root is reseeded
171
214
  # from /etc/skel-root and the harness profile (cookies) is wiped. This
172
215
  # re-injects the stored session from social_accounts via the HTTP API.
173
216
  # No-op when already logged in. Never blocks the cycle on failure.
174
- python3 "$HOME/social-autoposter/scripts/restore_twitter_session.py" 2>&1 | sed 's/^/[restore] /' >&2 || true
217
+ python3 "$_BH_REPO_DIR/scripts/restore_twitter_session.py" 2>&1 | sed 's/^/[restore] /' >&2 || true
175
218
  return 0
176
219
  fi
177
- echo "[$(date +%H:%M:%S)] ERROR: TWITTER_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host (AppMaker /opt/startup.sh, etc)." >&2
220
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: TWITTER_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host (AppMaker /opt/startup.sh, etc)." >&2
178
221
  return 1
179
222
  fi
180
- # Probe + launch harness Chrome on port 9555 if needed.
223
+ # Probe + launch harness Chrome on port 9555 if needed. Two-stage probe:
224
+ # /json/version (liveness) then a real CDP handshake (readiness). A wedged
225
+ # Chrome passes the first and fails the second; handing it downstream made
226
+ # every attach eat Playwright's 180s default while holding the browser lock
227
+ # (S4L-4H), so it gets killed and relaunched here instead.
228
+ local _need_launch=0 _launch_reason=""
229
+ local _bh_prof_dir="$HOME/.claude/browser-profiles/browser-harness"
181
230
  if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
182
- echo "[$(date +%H:%M:%S)] Harness Chrome down on port 9555, launching..." >&2
231
+ _need_launch=1; _launch_reason="http_down"
232
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Harness Chrome down on port 9555, launching..." >&2
233
+ else
234
+ local _ready_verdict
235
+ # TWO-STRIKE gate (2026-07-13): one failed handshake is NOT proof of a
236
+ # wedge. Under heavy system load (observed at loadavg ~18) a perfectly
237
+ # healthy Chrome blows the 8s Playwright attach budget, and a
238
+ # single-failure kill produced 3 false wedge-kills in one day on the
239
+ # operator Mac — each one a relaunch flash plus an "Aw, Snap" corpse
240
+ # tab from the SIGKILLed renderer. First failure: stamp a strike file
241
+ # and proceed WITHOUT killing (a truly wedged Chrome makes the cycle's
242
+ # own pre-flight fail loudly right after, so nothing silently hangs).
243
+ # Second consecutive failure within 30 minutes: genuinely wedged, kill
244
+ # + relaunch. A passing check clears any stale strike.
245
+ local _strike="/tmp/s4l_cdp_wedge_strike_9555"
246
+ if ! _ready_verdict=$(_bh_cdp_ready "$_BH_DEFAULT_URL"); then
247
+ if [ -z "$(find "$_strike" -mmin -30 2>/dev/null)" ]; then
248
+ touch "$_strike"
249
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Harness Chrome failed the CDP handshake check (${_ready_verdict:-no verdict}); first strike — NOT killing (loaded-machine tolerance). Second consecutive failure within 30m triggers the wedge heal." >&2
250
+ echo "twitter_cdp_wedge: detected url=$_BH_DEFAULT_URL action=first_strike" >&2
251
+ _bh_record_cdp_health wedge_first_strike "$_ready_verdict"
252
+ # Fall through WITHOUT killing: session restore + tab cleanup
253
+ # below still run.
254
+ else
255
+ rm -f "$_strike" 2>/dev/null || true
256
+ _need_launch=1; _launch_reason="cdp_wedge"
257
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Harness Chrome WEDGED on port 9555 (second consecutive handshake failure: ${_ready_verdict:-no verdict}); killing and relaunching..." >&2
258
+ # Machine-greppable marker (same stderr-marker convention as
259
+ # twitter_access_gate; bin/server.js parses these).
260
+ echo "twitter_cdp_wedge: detected url=$_BH_DEFAULT_URL action=restart" >&2
261
+ _bh_record_cdp_health wedge_restart "$_ready_verdict"
262
+ local _wedge_pids
263
+ _wedge_pids=$(pgrep -f -- "--user-data-dir=$_bh_prof_dir " 2>/dev/null || true)
264
+ if [ -n "$_wedge_pids" ]; then
265
+ kill $_wedge_pids 2>/dev/null || true
266
+ sleep 2
267
+ _wedge_pids=$(pgrep -f -- "--user-data-dir=$_bh_prof_dir " 2>/dev/null || true)
268
+ [ -n "$_wedge_pids" ] && { kill -9 $_wedge_pids 2>/dev/null || true; sleep 1; }
269
+ rm -f "$_bh_prof_dir/SingletonLock" "$_bh_prof_dir/SingletonSocket" "$_bh_prof_dir/SingletonCookie" 2>/dev/null || true
270
+ fi
271
+ fi
272
+ else
273
+ # Healthy handshake — clear any earlier strike so isolated blips
274
+ # never accumulate into a kill.
275
+ rm -f "$_strike" 2>/dev/null || true
276
+ fi
277
+ fi
278
+ if [ "$_need_launch" = 1 ]; then
279
+ # Dated relaunch stamp for central observability: memory_snapshot.py
280
+ # counts these (chrome_relaunches block) so a kill-respawn loop like
281
+ # 2026-07-12's launchd pgroup reaping is visible per-install in Cloud
282
+ # Logging without depending on the menubar foreground observer.
283
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) port=9555 reason=${_launch_reason}" \
284
+ >> "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/logs/chrome-relaunch-events.log" 2>/dev/null || true
183
285
  local _chrome_bin
184
286
  _chrome_bin=$(_resolve_chrome_bin)
185
287
  if [ -z "$_chrome_bin" ]; then
186
- echo "[$(date +%H:%M:%S)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
288
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: no Chrome/Chromium binary found. Set BH_CHROME_BIN." >&2
187
289
  return 1
188
290
  fi
189
291
  # On Linux + no display, run headless. On root, add --no-sandbox.
@@ -225,14 +327,20 @@ ensure_twitter_browser_for_backend() {
225
327
  local _stale_pids
226
328
  _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
227
329
  if [ -n "$_stale_pids" ] && ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
228
- echo "[$(date +%H:%M:%S)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
330
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] CDP down but Chrome still holds $_prof_dir (pids: $(echo $_stale_pids | tr '\n' ' ')); reaping stale profile owner before relaunch" >&2
229
331
  kill $_stale_pids 2>/dev/null || true
230
332
  sleep 2
231
333
  _stale_pids=$(pgrep -f -- "--user-data-dir=$_prof_dir " 2>/dev/null || true)
232
334
  [ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
233
335
  rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
234
336
  fi
235
- "$_chrome_bin" \
337
+ # Spawn via the SHARED launcher (skill/lib/browser-launch.sh): clean-
338
+ # exit stamp (no Aw-Snap restore), `open -n -g` on macOS (no focus
339
+ # steal, out of this job's pgroup), setsid exec fallback elsewhere.
340
+ # Do NOT hand-roll launch logic per backend.
341
+ # shellcheck disable=SC1091
342
+ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/browser-launch.sh"
343
+ launch_harness_chrome "$_chrome_bin" "$_prof_dir" \
236
344
  --remote-debugging-port=9555 \
237
345
  --user-data-dir="$HOME/.claude/browser-profiles/browser-harness" \
238
346
  --no-first-run --no-default-browser-check \
@@ -240,17 +348,30 @@ ensure_twitter_browser_for_backend() {
240
348
  --disable-features=ChromeWhatsNewUI,CalculateNativeWinOcclusion \
241
349
  --disable-backgrounding-occluded-windows \
242
350
  "${_extra[@]}" \
243
- "${BH_LAUNCH_URL:-https://x.com}" >/dev/null 2>&1 &
244
- disown
351
+ "${BH_LAUNCH_URL:-https://x.com}"
245
352
  for _i in 1 2 3 4 5 6 7 8 9 10 11 12; do
246
353
  curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null && break
247
354
  sleep 1
248
355
  done
249
356
  if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
250
- echo "[$(date +%H:%M:%S)] ERROR: harness Chrome failed to start within 12s" >&2
357
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: harness Chrome failed to start within 12s" >&2
358
+ _bh_record_cdp_health launch_failed null
359
+ return 1
360
+ fi
361
+ # Verify the fresh Chrome actually completes a CDP handshake before
362
+ # declaring victory; a relaunch that comes up wedged again should fail
363
+ # the cycle loudly, not feed 180s hangs downstream.
364
+ local _post_verdict
365
+ if ! _post_verdict=$(_bh_cdp_ready "$_BH_DEFAULT_URL"); then
366
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ERROR: harness Chrome answers HTTP after relaunch but the CDP handshake is STILL failing: ${_post_verdict:-no verdict}" >&2
367
+ echo "twitter_cdp_wedge: detected url=$_BH_DEFAULT_URL action=relaunch_failed" >&2
368
+ _bh_record_cdp_health relaunch_failed "$_post_verdict"
251
369
  return 1
252
370
  fi
253
- echo "[$(date +%H:%M:%S)] Harness Chrome up on port 9555" >&2
371
+ _bh_record_cdp_health relaunched "$_post_verdict"
372
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Harness Chrome up on port 9555" >&2
373
+ else
374
+ _bh_record_cdp_health ok "${_ready_verdict:-null}"
254
375
  fi
255
376
  # Re-inject the stored X session if the harness Chrome is logged out — e.g. a
256
377
  # keychain re-lock wiped Chrome's encrypted Cookies SQLite on this launch
@@ -259,7 +380,7 @@ ensure_twitter_browser_for_backend() {
259
380
  # No-op when already logged in; never blocks the cycle on failure. Runs on
260
381
  # both the freshly-launched and already-up paths so a mid-life logout heals.
261
382
  TWITTER_CDP_URL="http://127.0.0.1:9555" \
262
- python3 "$HOME/social-autoposter/scripts/restore_twitter_session.py" 2>&1 \
383
+ python3 "$_BH_REPO_DIR/scripts/restore_twitter_session.py" 2>&1 \
263
384
  | sed 's/^/[restore] /' >&2 || true
264
385
  # Always close leftover tabs from prior runs. Safe under acquire_lock
265
386
  # "twitter-browser" serialization (every caller of this function holds
@@ -290,7 +411,7 @@ defer_if_foreign_for_backend() {
290
411
  _sa_harness_log() {
291
412
  # Use the caller's log() FUNCTION when present; `declare -F` matches only a
292
413
  # shell function, never the macOS /usr/bin/log binary (command -v would).
293
- if declare -F log >/dev/null 2>&1; then log "$*"; else echo "[$(date +%H:%M:%S)] $*" >&2; fi
414
+ if declare -F log >/dev/null 2>&1; then log "$*"; else echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" >&2; fi
294
415
  }
295
416
  _sa_resolve_uv() {
296
417
  local c
@@ -30,7 +30,7 @@ LE_HELPER="$REPO_DIR/scripts/link_edit_helper.py"
30
30
  mkdir -p "$LOG_DIR"
31
31
  LOG_FILE="$LOG_DIR/link-edit-github-$(date +%Y-%m-%d_%H%M%S).log"
32
32
 
33
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
33
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
34
34
 
35
35
  RUN_START=$(date +%s)
36
36
  log "=== GitHub Link Edit Run: $(date) ==="
@@ -29,7 +29,7 @@ LE_HELPER="$REPO_DIR/scripts/link_edit_helper.py"
29
29
  mkdir -p "$LOG_DIR"
30
30
  LOG_FILE="$LOG_DIR/link-edit-moltbook-$(date +%Y-%m-%d_%H%M%S).log"
31
31
 
32
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
32
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
33
33
 
34
34
  RUN_START=$(date +%s)
35
35
  log "=== Moltbook Link Edit Run: $(date) ==="
@@ -38,7 +38,7 @@ LE_HELPER="$REPO_DIR/scripts/link_edit_helper.py"
38
38
  mkdir -p "$LOG_DIR"
39
39
  LOG_FILE="$LOG_DIR/link-edit-reddit-$(date +%Y-%m-%d_%H%M%S).log"
40
40
 
41
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
41
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
42
42
 
43
43
  RUN_START=$(date +%s)
44
44
  log "=== Reddit Link Edit Run: $(date) ==="
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+ # linkedin-cadence.sh — enforce the 4-active-day / 2-day-break LinkedIn
3
+ # posting schedule (user instruction, 2026-07-11). Fired every 15 minutes by
4
+ # launchd (com.m13v.social-linkedin-cadence). All logic lives in
5
+ # scripts/linkedin_cadence.py; this wrapper just logs the tick.
6
+
7
+ set -uo pipefail
8
+ export PATH="/opt/homebrew/bin:$PATH"
9
+
10
+ REPO_DIR="$HOME/social-autoposter"
11
+ LOG_DIR="$REPO_DIR/skill/logs"
12
+ mkdir -p "$LOG_DIR"
13
+ LOG="$LOG_DIR/linkedin-cadence.log"
14
+
15
+ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG" >&2; }
16
+
17
+ PY="/opt/homebrew/bin/python3"
18
+ [ -x "$PY" ] || PY="/usr/bin/python3"
19
+
20
+ "$PY" "$REPO_DIR/scripts/linkedin_cadence.py" enforce >>"$LOG" 2>&1
@@ -25,7 +25,7 @@ BATCH_ID="lipres-$(date +%Y%m%d_%H%M%S)-$$"
25
25
  export SA_CYCLE_ID="$BATCH_ID"
26
26
  export S4L_PIPELINE_NAME="linkedin-presence"
27
27
 
28
- log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
28
+ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_FILE"; }
29
29
 
30
30
  DRY_RUN="${LINKEDIN_PRESENCE_DRY_RUN:-0}"
31
31