social-autoposter 1.6.64 → 1.6.65

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.64",
3
- "installedAt": "2026-06-05T16:30:35.428Z"
2
+ "version": "1.6.65",
3
+ "installedAt": "2026-06-17T17:51:02.716Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
- "display_name": "Social Autoposter (X / Twitter)",
4
+ "display_name": "Social Autoposter",
5
5
  "version": "0.0.1",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the social-autoposter pipeline.",
7
7
  "long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Fully quit and restart Claude (quit and reopen, not just close the window).\n2. Start a new session and type **run social autoposter dashboard now** to set up the local runtime.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.64",
3
+ "version": "1.6.65",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -0,0 +1,9 @@
1
+ import sys,json
2
+ d=json.load(sys.stdin)
3
+ print("result_count:",d.get("result_count"),"error:",d.get("error"))
4
+ for r in d.get("results",[])[:5]:
5
+ name=r.get("author_name")
6
+ hl=(r.get("author_headline") or "")[:75]
7
+ t=(r.get("post_text") or "")[:170].replace("\n"," ")
8
+ print(" - %s | %s | age=%sh rx=%s c=%s vs=%s" % (name,hl,r.get("age_hours"),r.get("reactions"),r.get("comments"),r.get("velocity_score")))
9
+ print(" "+t)
@@ -0,0 +1,76 @@
1
+ import json, re
2
+
3
+ items = json.load(open("/tmp/li_actionable.json"))
4
+ cids = set(l.strip() for l in open("/tmp/li_cids.txt") if l.strip())
5
+ pairs = set(l.strip() for l in open("/tmp/li_pairs.txt") if l.strip())
6
+ posts = []
7
+ for l in open("/tmp/li_posts.txt"):
8
+ l=l.strip()
9
+ if not l: continue
10
+ pid, _, url = l.partition("|")
11
+ posts.append((pid, url))
12
+
13
+ EXCLUDED = {"louis030195","louis3195"}
14
+ OWN = {"matthew diakonov","m13v"}
15
+
16
+ def parse_urn(urn):
17
+ # urn:li:comment:(NS:PARENT,COMMENT)
18
+ m = re.match(r"urn:li:comment:\((\w+):(\d+),(\d+)\)", urn or "")
19
+ if not m: return (None,None,None)
20
+ return m.group(1), m.group(2), m.group(3)
21
+
22
+ def find_post_id(parent_id):
23
+ for pid, url in posts:
24
+ if parent_id and parent_id in url:
25
+ return pid
26
+ return None
27
+
28
+ def author_engaged(author, parent_id):
29
+ # any engaged pair with same author AND url containing parent_id
30
+ al = author.strip().lower()
31
+ for p in pairs:
32
+ a, _, url = p.partition("|||")
33
+ if a.strip().lower()==al and parent_id and parent_id in url:
34
+ return True
35
+ return False
36
+
37
+ seen_batch = set()
38
+ plan = []
39
+ counts = dict(new=0, already=0, engaged=0, excluded=0, own=0, nourn=0, dup_batch=0)
40
+
41
+ for it in items:
42
+ urn = it.get("comment_urn")
43
+ author = (it.get("author") or "").strip()
44
+ ns, parent_id, comment_id = parse_urn(urn)
45
+ rec = dict(it, ns=ns, parent_id=parent_id, comment_id=comment_id, decision=None, post_id=None)
46
+
47
+ if not urn or not parent_id:
48
+ rec["decision"]="skip:no_comment_urn"; counts["nourn"]+=1; plan.append(rec); continue
49
+ if urn in seen_batch:
50
+ rec["decision"]="skip:dup_in_batch"; counts["dup_batch"]+=1; plan.append(rec); continue
51
+ seen_batch.add(urn)
52
+ if urn in cids:
53
+ rec["decision"]="skip:already_tracked"; counts["already"]+=1; plan.append(rec); continue
54
+ al = author.lower()
55
+ if al in OWN or any(al==e for e in EXCLUDED):
56
+ rec["decision"]="skip:own_or_excluded"
57
+ if al in OWN: counts["own"]+=1
58
+ else: counts["excluded"]+=1
59
+ plan.append(rec); continue
60
+ if author_engaged(author, parent_id):
61
+ rec["decision"]="skip:author_already_engaged"; counts["engaged"]+=1; plan.append(rec); continue
62
+ pid = find_post_id(parent_id)
63
+ rec["post_id"]=pid
64
+ rec["decision"]="insert" if pid else "create_post+insert"
65
+ counts["new"]+=1
66
+ plan.append(rec)
67
+
68
+ json.dump(plan, open("/tmp/li_plan.json","w"), indent=2)
69
+ print("COUNTS:", counts)
70
+ print("TOTAL inspected:", len(items))
71
+ print()
72
+ for r in plan:
73
+ if r["decision"].startswith("skip"): continue
74
+ print(f"[{r['decision']}] {r['author']} | ns={r['ns']} parent={r['parent_id']} post_id={r['post_id']}")
75
+ print(f" urn: {r['comment_urn']}")
76
+ print(f" snip: {r['snippet'][:120]}")
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ import json, re, subprocess, os, sys
3
+
4
+ REPO = os.path.expanduser("~/social-autoposter")
5
+ LID = os.path.join(REPO, "scripts", "li_discovery.py")
6
+
7
+ EXCLUDED_AUTHORS = {"louis030195", "louis3195"}
8
+ OWN = {"matthew diakonov", "m13v"}
9
+
10
+ def run(args):
11
+ r = subprocess.run([sys.executable, LID] + args, capture_output=True, text=True)
12
+ return (r.stdout or "").strip(), (r.stderr or "").strip(), r.returncode
13
+
14
+ # one context dump
15
+ ctx_out, ctx_err, rc = run(["context"])
16
+ ctx = json.loads(ctx_out) if ctx_out else {}
17
+ existing = set(ctx.get("existing_comment_ids") or [])
18
+ engaged_pairs = ctx.get("engaged_pairs") or []
19
+ posts = ctx.get("posts") or []
20
+
21
+ PARENT_RE = re.compile(r"urn:li:(?:activity|ugcPost|share):(\d+)")
22
+
23
+ # build parent_id -> post_id map
24
+ parent_to_post = {}
25
+ for p in posts:
26
+ u = p.get("our_url") or ""
27
+ m = PARENT_RE.search(u)
28
+ if m:
29
+ parent_to_post.setdefault(m.group(1), p["id"])
30
+
31
+ # build engaged set (author_lower, parent_id)
32
+ engaged_set = set()
33
+ for pair in engaged_pairs:
34
+ if "|||" not in pair:
35
+ continue
36
+ author, url = pair.split("|||", 1)
37
+ m = PARENT_RE.search(url)
38
+ if m:
39
+ engaged_set.add((author.strip().lower(), m.group(1)))
40
+
41
+ CU_RE = re.compile(r"\((?:activity|ugcPost|share):(\d+),(\d+)\)")
42
+
43
+ data = json.load(open("/tmp/li_notifs.json"))
44
+
45
+ counts = dict(scanned=len(data), new=0, already=0, engaged=0, excluded=0, own=0, no_urn=0)
46
+ new_items = []
47
+
48
+ def proj_for(snippet):
49
+ s = (snippet or "").lower()
50
+ # our niche is claude code / ai agents -> fazm flagship
51
+ if any(k in s for k in ["claude code", "claude.md", "agent", "context window", "mcp", "subagent", "harness", "codex", "anthropic", "llm", "ai "]):
52
+ return "fazm"
53
+ return "general"
54
+
55
+ for it in data:
56
+ cu = it.get("comment_urn")
57
+ author = (it.get("author") or "").strip()
58
+ if not cu:
59
+ counts["no_urn"] += 1
60
+ continue
61
+ m = CU_RE.search(cu)
62
+ if not m:
63
+ counts["no_urn"] += 1
64
+ continue
65
+ parent_id = m.group(1)
66
+ al = author.lower()
67
+ # exclusion
68
+ if al in OWN or author in ("unknown",):
69
+ counts["own"] += 1
70
+ continue
71
+ if al in EXCLUDED_AUTHORS or any(x in al for x in EXCLUDED_AUTHORS):
72
+ counts["excluded"] += 1
73
+ continue
74
+ if cu in existing:
75
+ counts["already"] += 1
76
+ continue
77
+ if (al, parent_id) in engaged_set:
78
+ counts["engaged"] += 1
79
+ continue
80
+ # find or create post
81
+ post_id = parent_to_post.get(parent_id)
82
+ if not post_id:
83
+ proj = proj_for(it.get("snippet"))
84
+ out, err, rc = run(["create-post", "--activity-id", parent_id, "--project", proj, "--author", author])
85
+ post_id = out.strip().splitlines()[-1] if out.strip() else ""
86
+ if not post_id:
87
+ print(f" [create-post FAILED parent={parent_id}] err={err}", file=sys.stderr)
88
+ continue
89
+ parent_to_post[parent_id] = post_id
90
+ # insert reply
91
+ out, err, rc = run([
92
+ "insert-reply", "--post-id", str(post_id),
93
+ "--comment-urn", cu, "--author", author,
94
+ "--content", (it.get("snippet") or "")[:3000],
95
+ "--href", it.get("href") or "",
96
+ ])
97
+ res = out.strip().splitlines()[-1] if out.strip() else ""
98
+ if res == "duplicate":
99
+ counts["already"] += 1
100
+ elif res.startswith("gated"):
101
+ counts["engaged"] += 1 # gated by blocklist/velocity, not actionable
102
+ print(f" [gated] {author} parent={parent_id} -> {res}", file=sys.stderr)
103
+ elif res:
104
+ counts["new"] += 1
105
+ new_items.append((res, author, parent_id, cu))
106
+ # mark as existing to dedup within this run
107
+ existing.add(cu)
108
+ engaged_set.add((al, parent_id))
109
+ else:
110
+ print(f" [insert FAILED] {author} parent={parent_id} err={err}", file=sys.stderr)
111
+
112
+ print("\n=== NEW REPLIES INSERTED ===")
113
+ for rid, author, parent, cu in new_items:
114
+ print(f" reply_id={rid} author={author} parent={parent}")
115
+
116
+ print("\n=== SUMMARY ===")
117
+ print(f"New replies discovered: {counts['new']}")
118
+ print(f"Already tracked: {counts['already']}")
119
+ print(f"Author already engaged thread: {counts['engaged']}")
120
+ print(f"Excluded: {counts['excluded']}")
121
+ print(f"Own account: {counts['own']}")
122
+ print(f"No comment URN: {counts['no_urn']}")
123
+ print(f"Total scanned: {counts['scanned']}")
124
+
125
+ excl_total = counts["excluded"] + counts["own"]
126
+ print(f"\nLINKEDIN_SCAN_SUMMARY: scanned={counts['scanned']} new={counts['new']} already={counts['already']} excluded={excl_total} unmatched={counts['no_urn']}")
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Throwaway helper: run dm_conversation.py set-icp-precheck for every project.
3
+
4
+ Usage:
5
+ python3 _run_icp_precheck.py --dm-id 4583 --default-label icp_miss \
6
+ --default-notes "crypto researcher, not target vertical" \
7
+ --override 'fazm=icp_miss=engaged on agentic token cost but no claude-code-wrapper signal' \
8
+ --override 'Agora=icp_miss=crypto researcher not a protocol governance buyer'
9
+
10
+ Any project NOT overridden gets the default label/notes.
11
+ """
12
+ import argparse, subprocess, sys
13
+
14
+ PROJECTS = [
15
+ "fazm", "Terminator", "macOS MCP", "Vipassana", "S4L", "AI Browser Profile",
16
+ "WhatsApp MCP", "macOS Session Replay", "Cyrano", "Assrt", "PieLine", "Clone",
17
+ "mk0r", "fde10x", "claude-meter", "c0nsl", "tenxats", "paperback-expert",
18
+ "studyly", "Mediar", "NightOwl", "Runner", "Agora", "Podlog", "ccmd",
19
+ ]
20
+ SCRIPT = "/Users/matthewdi/social-autoposter/scripts/dm_conversation.py"
21
+
22
+ def main():
23
+ ap = argparse.ArgumentParser()
24
+ ap.add_argument("--dm-id", required=True)
25
+ ap.add_argument("--default-label", default="icp_miss")
26
+ ap.add_argument("--default-notes", default="not target vertical")
27
+ ap.add_argument("--override", action="append", default=[],
28
+ help="PROJECT=LABEL=NOTES")
29
+ args = ap.parse_args()
30
+
31
+ overrides = {}
32
+ for o in args.override:
33
+ parts = o.split("=", 2)
34
+ if len(parts) != 3:
35
+ print("bad override:", o); sys.exit(2)
36
+ overrides[parts[0]] = (parts[1], parts[2])
37
+
38
+ # validate override keys
39
+ for k in overrides:
40
+ if k not in PROJECTS:
41
+ print("UNKNOWN project in override:", k); sys.exit(2)
42
+
43
+ fails = 0
44
+ for p in PROJECTS:
45
+ label, notes = overrides.get(p, (args.default_label, args.default_notes))
46
+ r = subprocess.run(
47
+ ["python3", SCRIPT, "set-icp-precheck", "--dm-id", args.dm_id,
48
+ "--project", p, "--label", label, "--notes", notes],
49
+ capture_output=True, text=True)
50
+ tag = "ok" if r.returncode == 0 else "FAIL"
51
+ if r.returncode != 0:
52
+ fails += 1
53
+ print(f"{tag} {p}={label}", (r.stderr or r.stdout or "").strip()[:120])
54
+ print(f"DONE dm={args.dm_id} fails={fails}")
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,39 @@
1
+ # exec'd inside browser-harness; helpers (goto_url, js, click_at_xy, type_text,
2
+ # press_key, page_info, wait_for_load) are available as globals.
3
+ import time as _t, json as _j
4
+ def send(A, MSG, UNIQ):
5
+ goto_url("https://x.com/"+A); wait_for_load(); _t.sleep(2.5)
6
+ prof = js(r"""
7
+ (() => {
8
+ const nameEl=document.querySelector('[data-testid="UserName"]');
9
+ const bioEl=document.querySelector('[data-testid="UserDescription"]');
10
+ let followers=null;
11
+ document.querySelectorAll('a[href$="/verified_followers"],a[href$="/followers"]').forEach(a=>{followers=a.innerText.replace(/\n/g,' ');});
12
+ const tweets=[...document.querySelectorAll('[data-testid="tweetText"]')].slice(0,3).map(t=>t.innerText.slice(0,120).replace(/\n/g,' '));
13
+ const b=document.querySelector('[data-testid="sendDMFromProfile"]');
14
+ let msgRect=null; if(b){const r=b.getBoundingClientRect(); msgRect={x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}
15
+ const suspended=/This account doesn|Account suspended|Hmm.*went wrong|Caution/i.test(document.body.innerText.slice(0,300));
16
+ return {name:nameEl?nameEl.innerText.replace(/\n/g,' | '):null, bio:bioEl?bioEl.innerText.replace(/\n/g,' '):null, followers, tweets, hasMsg:!!b, msgRect, suspended};
17
+ })()
18
+ """)
19
+ prof['tweets']=(prof.get('tweets') or [])[:1]
20
+ prof['bio']=(prof.get('bio') or '')[:140]
21
+ out={"prof":prof}
22
+ if prof.get('suspended') or not prof.get('hasMsg'):
23
+ out["status"]="no_dm"; print(_j.dumps(out,ensure_ascii=True)[:2400]); return
24
+ click_at_xy(prof['msgRect']['x'],prof['msgRect']['y']); _t.sleep(3)
25
+ rect=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); if(!t)return null; const r=t.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()""")
26
+ if not rect:
27
+ out["status"]="no_composer"; out["url"]=page_info().get('url'); print(_j.dumps(out,ensure_ascii=True)[:2400]); return
28
+ click_at_xy(rect['x'],rect['y']); _t.sleep(0.6)
29
+ type_text(MSG); _t.sleep(0.9)
30
+ val=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); return t?t.value:null;})()""")
31
+ if (val or "").strip()!=MSG:
32
+ out["status"]="type_mismatch"; out["len"]=len(val or ""); out["url"]=page_info().get('url'); print(_j.dumps(out,ensure_ascii=True)[:2400]); return
33
+ press_key("Enter"); _t.sleep(2.6)
34
+ chk=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); const body=document.body.innerText; return {cleared:t?t.value.trim()==='':null, url:location.href, bodyHasHandle:body.includes('@__A__')};})()""".replace('__A__',A))
35
+ full=js(r"""(()=>document.body.innerText)()""") or ""
36
+ chk["hasPhrase"]= UNIQ in full
37
+ out["status"]="sent" if (chk.get('cleared') and chk.get('hasPhrase')) else "send_unverified"
38
+ out["url"]=chk.get('url'); out["verify"]=chk
39
+ print(_j.dumps(out,ensure_ascii=True)[:2400])
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env python3
2
+ """Bulk-record ICP prechecks for one dm in a single process.
3
+ Usage: bulk_icp.py DM_ID 'project=label:notes' 'project2=label2:notes2' ...
4
+ label in {icp_match,icp_miss,disqualified,unknown}
5
+ """
6
+ import sys, subprocess
7
+ dm_id=sys.argv[1]
8
+ for arg in sys.argv[2:]:
9
+ proj, rest = arg.split('=',1)
10
+ if ':' in rest:
11
+ label, notes = rest.split(':',1)
12
+ else:
13
+ label, notes = rest, ''
14
+ cmd=['python3','scripts/dm_conversation.py','set-icp-precheck','--dm-id',dm_id,
15
+ '--project',proj,'--label',label]
16
+ if notes: cmd += ['--notes',notes]
17
+ r=subprocess.run(cmd,capture_output=True,text=True)
18
+ print(proj,label,'->',('ok' if r.returncode==0 else 'ERR '+r.stderr.strip()[:80]))
@@ -203,9 +203,47 @@ def main():
203
203
  persisted = 0
204
204
  with_media = 0
205
205
  reposts = 0
206
+ access_checked = 0
207
+ access_not_visible = 0
208
+ access_check_limit = int(os.environ.get("SAPS_TWITTER_EMPTY_MEDIA_ACCESS_CHECKS", "3"))
209
+ access_wait_ms = int(os.environ.get("SAPS_TWITTER_EMPTY_MEDIA_ACCESS_WAIT_MS", "4000"))
210
+ empty_capture_unreliable = None
206
211
  for cid, url in pairs:
207
212
  rec = by_url.get(url) or {}
208
213
  media = rec.get("media", [])
214
+ access = None
215
+ if not media:
216
+ # Empty media is only meaningful when the tweet itself rendered.
217
+ # If x.com served an empty app shell / block / protected page, do
218
+ # NOT persist [] because [] means "captured successfully, no media";
219
+ # leaving NULL lets a later cycle retry instead of poisoning the row.
220
+ if empty_capture_unreliable:
221
+ access = empty_capture_unreliable
222
+ elif access_checked < access_check_limit:
223
+ try:
224
+ from twitter_access_check import diagnose_tweet_access
225
+ access = diagnose_tweet_access(
226
+ url, wait_ms=access_wait_ms, include_public=False,
227
+ )
228
+ access_checked += 1
229
+ except Exception as e:
230
+ access = {"status": "access_check_failed", "reason": str(e)}
231
+ access_checked += 1
232
+ else:
233
+ access = {"status": "unchecked", "reason": "empty_media_access_check_cap"}
234
+ status = access.get("status")
235
+ if status not in ("visible", "visible_no_anchor", "unchecked"):
236
+ access_not_visible += 1
237
+ if status in ("app_not_hydrated", "app_error", "logged_out", "access_check_failed", "unknown"):
238
+ empty_capture_unreliable = access
239
+ print(
240
+ f"[capture_thread_media] cid={cid} url={url} "
241
+ f"access_status={status} reason={access.get('reason')} "
242
+ "leaving thread_media NULL",
243
+ file=sys.stderr,
244
+ )
245
+ captured.append((cid, media, {"is_repost": False, "reposted_by": ""}))
246
+ continue
209
247
  fresh = rec.get("repost", {"is_repost": False, "reposted_by": ""})
210
248
  # Authoritative repost flag comes from discovery (stored). Fresh permalink
211
249
  # detection is a rare bonus; prefer stored, fall back to fresh.
@@ -240,6 +278,8 @@ def main():
240
278
  "persisted": persisted,
241
279
  "with_media": with_media,
242
280
  "reposts": reposts,
281
+ "access_checked": access_checked,
282
+ "access_not_visible": access_not_visible,
243
283
  "urls_visited": (batch or {}).get("urls_visited", 0),
244
284
  }), file=sys.stderr)
245
285
 
@@ -0,0 +1,81 @@
1
+ #!/bin/bash
2
+ # Health check for the browser session-lock fix (2026-06-16).
3
+ # Full context: docs/twitter_browser_lock.md. Logic test: scripts/test_browser_lock.py.
4
+ #
5
+ # Prints [ok ] / [BAD] / [i ] lines so you can tell at a glance whether the fix is
6
+ # present in code AND behaving in production. Exits non-zero if any [BAD] is found.
7
+ #
8
+ # bash scripts/check_browser_lock_health.sh [HOURS] # default lookback 24h
9
+ #
10
+ # What it checks (see docs §4/§5 for the why):
11
+ # 1. fix still present in twitter_browser.py + linkedin_browser.py (catch a revert)
12
+ # 2. defect-b `rm -f` has NOT crept back into skill/*.sh
13
+ # 3. reclaim markers firing = fix actively catching dead holders (positive signal)
14
+ # 4. starvation giveups WITHOUT the peer-alive tell = defect (a) recurring (BAD)
15
+ # 5. shell-lock trap_rm owner=OTHER = a pipeline deleted a LIVE peer's lock (BAD)
16
+ set -u
17
+ cd "$(dirname "$0")/.."
18
+ LOGS="skill/logs"
19
+ HOURS="${1:-24}"
20
+ bad=0
21
+
22
+ # Dated per-run logs that are BOTH within the lookback window AND newer than the lock
23
+ # code file. The `-newer` clause is the key: it counts only runs that started after the
24
+ # fix (or, if the fix is ever reverted, after that revert) -- so day-one pre-fix
25
+ # starvation noise is excluded automatically, and a real regression still surfaces.
26
+ # (NOT launchd-*.log: those are append-only, so their mtime says nothing about when a
27
+ # line inside was written.)
28
+ recent=$(find "$LOGS" -maxdepth 1 -name '*2026-*.log' -mmin "-$((HOURS*60))" \
29
+ -newer scripts/twitter_browser.py 2>/dev/null | grep -v 'launchd-' || true)
30
+ [ -z "$recent" ] && recent="/dev/null" # guard: never let grep read stdin
31
+
32
+ echo "== browser-lock health (last ${HOURS}h of dated per-run logs) =="
33
+
34
+ # 1. fix present in code
35
+ if grep -q _is_python_holder_alive scripts/twitter_browser.py 2>/dev/null \
36
+ && grep -q _is_python_holder_alive scripts/linkedin_browser.py 2>/dev/null; then
37
+ echo "[ok ] fix present (twitter_browser.py + linkedin_browser.py)"
38
+ else
39
+ echo "[BAD] fix MISSING from code -> reverted (re-apply from docs/twitter_browser_lock.md)"; bad=1
40
+ fi
41
+
42
+ # 2. defect-b rm -f gone from shells (anchored so the explanatory comment is not a hit).
43
+ # Scope to *.sh only -- never recurse skill/ (it holds a huge claude-sessions/ tree).
44
+ if grep -hEq '^[[:space:]]*rm -f .*twitter-browser-lock\.json' skill/*.sh skill/lib/*.sh 2>/dev/null; then
45
+ echo "[BAD] defect-b: an actual 'rm -f ...twitter-browser-lock.json' is back in skill/*.sh"; bad=1
46
+ else
47
+ echo "[ok ] no rm -f of the session lock in skill/*.sh"
48
+ fi
49
+
50
+ # 3. positive: reclaim markers (each = a dead holder caught that USED to starve the fleet)
51
+ rec=$(grep -hoE '\[browser_lock\] reclaimed .*reason=[a-z_]+' $recent 2>/dev/null | wc -l | tr -d ' ')
52
+ recd=$(grep -hoE '\[browser_lock\] reclaimed .*reason=dead_python' $recent 2>/dev/null | wc -l | tr -d ' ')
53
+ echo "[i ] reclaim markers fired: ${rec:-0} (of which dead_python: ${recd:-0}) -- 0 is fine if nothing crashed"
54
+
55
+ # 4. starvation: twitter giveup without 'peer alive' / linkedin profile_locked without 'peer_alive'
56
+ bad_tw=$(grep -hE 'locked by session .* giving up' $recent 2>/dev/null | grep -vc 'peer alive' || true)
57
+ bad_li=$(grep -hE 'profile_locked' $recent 2>/dev/null | grep -vc 'peer_alive' || true)
58
+ bad_tw=${bad_tw:-0}; bad_li=${bad_li:-0}
59
+ if [ "$bad_tw" -gt 0 ] || [ "$bad_li" -gt 0 ]; then
60
+ echo "[BAD] old-format starvation giveups: twitter=$bad_tw linkedin=$bad_li (defect a recurring?)"; bad=1
61
+ else
62
+ echo "[ok ] no old-format starvation giveups (twitter + linkedin)"
63
+ fi
64
+
65
+ # 5. shell-lock: dangerous trap_rm owner=OTHER (deleting a live peer's shell lock).
66
+ # NOTE: 'event=stale_reclaim ... owner=OTHER' is LEGITIMATE (reclaiming a dead holder),
67
+ # only 'event=trap_rm ... owner=OTHER' is the bad one.
68
+ if [ -f "$LOGS/lock-events.log" ]; then
69
+ to=$(grep -cE 'event=trap_rm .*owner=OTHER' "$LOGS/lock-events.log" 2>/dev/null); to=${to:-0}
70
+ if [ "$to" -gt 0 ]; then
71
+ echo "[BAD] shell trap_rm owner=OTHER x$to (a pipeline deleted a LIVE peer's shell lock)"; bad=1
72
+ else
73
+ echo "[ok ] shell-lock: no trap_rm owner=OTHER (live-lock deletes)"
74
+ fi
75
+ fi
76
+
77
+ echo "=================================================="
78
+ if [ "$bad" -ne 0 ]; then
79
+ echo "RESULT: ATTENTION NEEDED (see [BAD] above)"; exit 1
80
+ fi
81
+ echo "RESULT: HEALTHY"