@m13v/s4l 1.7.7-rc.4 → 1.7.7-rc.6
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 +25 -70
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +6 -1
- package/mcp/menubar/s4l_card.py +10 -19
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +14 -0
- package/scripts/cleanup_harness_tabs.py +74 -11
- package/scripts/context_mining.py +638 -0
- package/scripts/twitter_browser.py +200 -25
- package/skill/engage-dm-replies.sh +13 -3
- package/skill/lib/harness-common.sh +32 -3
- package/skill/lib/linkedin-backend.sh +0 -1
- package/skill/lib/reddit-backend.sh +0 -1
- package/skill/lib/twitter-backend.sh +0 -1
package/mcp/dist/index.js
CHANGED
|
@@ -131,10 +131,13 @@ const STALL_WATCH_INTERVAL_SECS = 120;
|
|
|
131
131
|
// S4L_OVERLAY_WATCH=0.
|
|
132
132
|
const OVERLAY_WATCH_LABEL = "com.m13v.social-overlay-watch";
|
|
133
133
|
const OVERLAY_WATCH_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${OVERLAY_WATCH_LABEL}.plist`);
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// only
|
|
137
|
-
//
|
|
134
|
+
// Legacy daily self-updater launchd job. BANNED (user decision 2026-07-31):
|
|
135
|
+
// it ran s4l_box_update.sh unattended, which kills and relaunches Claude
|
|
136
|
+
// Desktop with no human in the loop. Updates are user-initiated only (menubar
|
|
137
|
+
// "Update now & restart Claude Desktop", the panel button, `runtime`
|
|
138
|
+
// action:'update', or an operator running s4l_box_update.sh explicitly). The
|
|
139
|
+
// label/plist constants survive so ensureUpdaterRemoved() can clean the job
|
|
140
|
+
// off every box that got it from v1.7.1..v1.7.7-rc.4.
|
|
138
141
|
const UPDATER_LABEL = "com.m13v.social-autoposter-update";
|
|
139
142
|
const UPDATER_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${UPDATER_LABEL}.plist`);
|
|
140
143
|
// A sane PATH for launchd jobs (launchd starts with a bare PATH). Include the
|
|
@@ -4874,68 +4877,21 @@ async function ensureOverlayWatchInstalled() {
|
|
|
4874
4877
|
return { ok: false, detail: e?.message || String(e) };
|
|
4875
4878
|
}
|
|
4876
4879
|
}
|
|
4877
|
-
//
|
|
4878
|
-
//
|
|
4879
|
-
//
|
|
4880
|
-
//
|
|
4881
|
-
//
|
|
4882
|
-
|
|
4883
|
-
// commit (auto_update_on reads false forever). Restored here as its own
|
|
4884
|
-
// deterministic boot-time job, same pattern as its five siblings above.
|
|
4885
|
-
//
|
|
4886
|
-
// Points at scripts/s4l_box_update.sh, NOT skill/social-autoposter-update.sh:
|
|
4887
|
-
// the latter is the npm-lane updater (npm view + npx update) and is a silent
|
|
4888
|
-
// no-op on a .mcpb box, which has no npm/npx on PATH (see version.ts). The
|
|
4889
|
-
// .mcpb-lane equivalent downloads the .mcpb directly from the channel-resolved
|
|
4890
|
-
// GitHub release and unpacks it over the extension dir — see that script's own
|
|
4891
|
-
// header for the channel/no-downgrade/retry guards. Default mode there
|
|
4892
|
-
// downloads + unpacks + restarts Claude Desktop with NO human in the loop,
|
|
4893
|
-
// matching the original bundled updater's intent ("keeps a headless install
|
|
4894
|
-
// current"); RunAtLoad so a box that boots already-behind checks promptly.
|
|
4895
|
-
async function ensureUpdaterInstalled() {
|
|
4880
|
+
// Remove the legacy daily self-updater launchd job (see UPDATER_LABEL's
|
|
4881
|
+
// comment). Boot-time, idempotent, best-effort: unload the job if launchd has
|
|
4882
|
+
// it and delete the plist so the daily silent Claude Desktop restart can never
|
|
4883
|
+
// fire again. Do NOT reintroduce an install path for this job — any future
|
|
4884
|
+
// auto-update mechanism must be user-visible and user-confirmed.
|
|
4885
|
+
async function ensureUpdaterRemoved() {
|
|
4896
4886
|
try {
|
|
4897
4887
|
if (process.platform !== "darwin")
|
|
4898
|
-
return { ok:
|
|
4899
|
-
if ((process.env.S4L_AUTO_UPDATE) === "0")
|
|
4900
|
-
return { ok: false, detail: "disabled (S4L_AUTO_UPDATE=0)" };
|
|
4901
|
-
const logDir = path.join(repoDir(), "skill", "logs");
|
|
4902
|
-
try {
|
|
4903
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
4904
|
-
}
|
|
4905
|
-
catch {
|
|
4906
|
-
/* best-effort */
|
|
4907
|
-
}
|
|
4908
|
-
const xml = plistXml({
|
|
4909
|
-
label: UPDATER_LABEL,
|
|
4910
|
-
programArgs: ["/bin/bash", path.join(repoDir(), "scripts", "s4l_box_update.sh")],
|
|
4911
|
-
intervalSecs: 86_400,
|
|
4912
|
-
runAtLoad: true,
|
|
4913
|
-
stdoutLog: path.join(logDir, "launchd-self-update-stdout.log"),
|
|
4914
|
-
stderrLog: path.join(logDir, "launchd-self-update-stderr.log"),
|
|
4915
|
-
});
|
|
4888
|
+
return { ok: true, detail: "not macOS" };
|
|
4916
4889
|
const uid = process.getuid ? process.getuid() : 0;
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
cur = null;
|
|
4923
|
-
}
|
|
4924
|
-
let detail;
|
|
4925
|
-
if (cur === xml) {
|
|
4926
|
-
const res = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
4927
|
-
detail = `current (load rc=${res.code})`;
|
|
4928
|
-
}
|
|
4929
|
-
else {
|
|
4930
|
-
if (cur !== null) {
|
|
4931
|
-
await unloadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
4932
|
-
}
|
|
4933
|
-
fs.mkdirSync(path.dirname(UPDATER_PLIST), { recursive: true });
|
|
4934
|
-
fs.writeFileSync(UPDATER_PLIST, xml, "utf-8");
|
|
4935
|
-
const res = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
4936
|
-
detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
|
|
4937
|
-
}
|
|
4938
|
-
return { ok: true, detail };
|
|
4890
|
+
const existed = fs.existsSync(UPDATER_PLIST);
|
|
4891
|
+
await unloadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
4892
|
+
if (existed)
|
|
4893
|
+
fs.rmSync(UPDATER_PLIST, { force: true });
|
|
4894
|
+
return { ok: true, detail: existed ? "removed" : "absent" };
|
|
4939
4895
|
}
|
|
4940
4896
|
catch (e) {
|
|
4941
4897
|
return { ok: false, detail: e?.message || String(e) };
|
|
@@ -6134,13 +6090,12 @@ async function main() {
|
|
|
6134
6090
|
void ensureOverlayWatchInstalled()
|
|
6135
6091
|
.then((r) => console.error(`[overlay-watch] launchd supervisor: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6136
6092
|
.catch((e) => console.error("[overlay-watch] supervisor install failed:", e?.message || e));
|
|
6137
|
-
//
|
|
6138
|
-
//
|
|
6139
|
-
//
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
.
|
|
6143
|
-
.catch((e) => console.error("[self-update] updater install failed:", e?.message || e));
|
|
6093
|
+
// Silent daily self-updates are banned (user decision 2026-07-31): the job
|
|
6094
|
+
// restarted Claude Desktop unattended. Clean it off every box that got it
|
|
6095
|
+
// from v1.7.1..v1.7.7-rc.4. Best-effort; never blocks boot.
|
|
6096
|
+
void ensureUpdaterRemoved()
|
|
6097
|
+
.then((r) => console.error(`[self-update] legacy launchd updater: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
6098
|
+
.catch((e) => console.error("[self-update] updater removal failed:", e?.message || e));
|
|
6144
6099
|
// Heal installs onboarded before short_links_live defaulted to false: such a
|
|
6145
6100
|
// project wraps short links against the customer's own domain, which has no
|
|
6146
6101
|
// /r/[code] resolver, so every minted link 404s. Re-point them at the s4l.ai
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.7-rc.
|
|
5
|
+
"version": "1.7.7-rc.6",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -156,9 +156,14 @@ class _Worker(threading.Thread):
|
|
|
156
156
|
# the foreground, hide that specific Chrome process by pid, then force
|
|
157
157
|
# focus back to the app the user was in. Screenshots/clicks are
|
|
158
158
|
# unaffected (CDP is offscreen-raster + synthetic input; the occlusion
|
|
159
|
-
# flags keep hidden tabs painting).
|
|
159
|
+
# flags keep hidden tabs painting).
|
|
160
|
+
# DEFAULT OFF (user decision 2026-07-30): only the telemetry above runs
|
|
161
|
+
# unless S4L_HARNESS_HIDE=1 opts in. S4L_NO_HARNESS_HIDE force-disables
|
|
162
|
+
# even when the opt-in is set.
|
|
160
163
|
if os.environ.get("S4L_NO_HARNESS_HIDE"):
|
|
161
164
|
return
|
|
165
|
+
if os.environ.get("S4L_HARNESS_HIDE") != "1":
|
|
166
|
+
return
|
|
162
167
|
pos = details.get("window_position") or ""
|
|
163
168
|
try:
|
|
164
169
|
offscreen = any(int(float(v)) < 0 for v in pos.split(",")[:2])
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -464,12 +464,12 @@ def _compact_arm(variant):
|
|
|
464
464
|
|
|
465
465
|
|
|
466
466
|
def _reply_heading_suffix(d):
|
|
467
|
-
"""Concise 'project
|
|
468
|
-
'Reply (editable):' heading (2026-07-07), so the
|
|
469
|
-
|
|
467
|
+
"""Concise 'project · viral N · t v4' readout appended straight onto the
|
|
468
|
+
'Reply (editable):' heading (2026-07-07), so the facts a reviewer checks
|
|
469
|
+
first don't require opening the details eye. Pulled from the same
|
|
470
470
|
fields _details_lines used to render as 'Project:'/'Lane:'/'Virality
|
|
471
|
-
score:' rows; those
|
|
472
|
-
|
|
471
|
+
score:' rows; those are omitted from the popover now to avoid showing
|
|
472
|
+
the same numbers twice. Empty string when neither is known."""
|
|
473
473
|
d = d or {}
|
|
474
474
|
project = (d.get("project") or "").strip()
|
|
475
475
|
lane = ((d.get("experiments") or {}).get("lane") or "").strip()
|
|
@@ -480,20 +480,11 @@ def _reply_heading_suffix(d):
|
|
|
480
480
|
# approve_() blocks posting on these and the reviewer should know why
|
|
481
481
|
# before clicking rather than after.
|
|
482
482
|
bits.append("⚠ SANDBOX — not postable")
|
|
483
|
-
#
|
|
484
|
-
# "
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
|
|
488
|
-
# collapses to one token instead of "PersonalBrand/personal_brand".
|
|
489
|
-
same = project and lane and re.sub(r"[^a-z0-9]", "", project.lower()) == re.sub(
|
|
490
|
-
r"[^a-z0-9]", "", lane.lower()
|
|
491
|
-
)
|
|
492
|
-
if same:
|
|
493
|
-
bits.append(project)
|
|
494
|
-
elif project and lane:
|
|
495
|
-
bits.append(f"{project}/{lane}")
|
|
496
|
-
elif project:
|
|
483
|
+
# Heading shows the project/lane name only, never a combined
|
|
484
|
+
# "project/lane" string (2026-07-31, user call: the "/promotion" suffix
|
|
485
|
+
# on cards was confusing). Lane is still stamped on the candidate and
|
|
486
|
+
# readable in the details popover; it's just not concatenated here.
|
|
487
|
+
if project:
|
|
497
488
|
bits.append(project)
|
|
498
489
|
elif lane:
|
|
499
490
|
bits.append(lane)
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.7-rc.
|
|
3
|
+
"version": "1.7.7-rc.6",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
package/scripts/claude_job.py
CHANGED
|
@@ -76,6 +76,12 @@ TAG_TO_TYPE = {
|
|
|
76
76
|
# Python and inlined; no Bash tools), mirroring the twitter Phase 2b
|
|
77
77
|
# tool-free conversion, so it rides the same universal worker.
|
|
78
78
|
"post-reddit-draft": "reddit-draft",
|
|
79
|
+
# Context mining (2026-07-31): scripts/context_mining.py inlines compacted
|
|
80
|
+
# transcripts + the corpus into one pure text->JSON turn. Execution
|
|
81
|
+
# guidance lives at the top of the prompt itself (not only in
|
|
82
|
+
# TYPE_TO_WORKER_NOTES) because an installed-package worker claims with
|
|
83
|
+
# ITS copy of this map, which may predate this type.
|
|
84
|
+
"context-mining": "context-mining",
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
# queue type -> (activity state, label) the menu bar shows while the job is in
|
|
@@ -92,6 +98,7 @@ TYPE_TO_ACTIVITY = {
|
|
|
92
98
|
# must stay concise (user rule 2026-07-14); the platform shows on the
|
|
93
99
|
# review card itself, not in the tray.
|
|
94
100
|
"reddit-draft": ("drafting", "draft"),
|
|
101
|
+
"context-mining": ("learning", "mining context"),
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
# queue type -> execution notes PREPENDED to the prompt sidecar at claim time.
|
|
@@ -140,6 +147,13 @@ TYPE_TO_WORKER_NOTES = {
|
|
|
140
147
|
"absent from posts (add a rejects entry only for the structural "
|
|
141
148
|
"false-positive cases the prompt describes)."
|
|
142
149
|
),
|
|
150
|
+
"context-mining": (
|
|
151
|
+
"WORKER EXECUTION NOTES (queue metadata; follow while executing the "
|
|
152
|
+
"prompt below): single-turn, tool-free job. Everything you need is "
|
|
153
|
+
"inlined in the prompt (transcripts, corpus, prior decisions). Never "
|
|
154
|
+
"use tools. Read, then submit ONE result object matching the schema: "
|
|
155
|
+
"{\"proposals\": [...]}. An empty proposals list is a valid answer."
|
|
156
|
+
),
|
|
143
157
|
}
|
|
144
158
|
|
|
145
159
|
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Close every CDP "page" tab in harness Chrome except one
|
|
2
|
+
"""Close every CDP "page" tab in harness Chrome except one, and keep that one
|
|
3
|
+
tab REAL (http/https).
|
|
3
4
|
|
|
4
|
-
Called from skill/lib/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
Called from skill/lib/harness-common.sh::hc_cleanup_tabs (all three platform
|
|
6
|
+
backends) as part of pre-flight. Safe to call any time: exits 0 silently when
|
|
7
|
+
harness Chrome is down. Workers and iframe targets are left alone; they
|
|
8
|
+
auto-clean when their parent page closes.
|
|
9
|
+
|
|
10
|
+
Revive (2026-07-31): when BH_REVIVE_URL is set to an http(s) url, a Chrome
|
|
11
|
+
whose only tab is about:blank gets that tab navigated to the revive url, and
|
|
12
|
+
a completely tabless Chrome (--no-startup-window launch) gets ONE background
|
|
13
|
+
tab created there. This matters because the harness daemon's is_real_page()
|
|
14
|
+
refuses about:/chrome: tabs — a blank-only browser used to trap it in a
|
|
15
|
+
"stale session -> foreground createTarget(about:blank) -> still not real"
|
|
16
|
+
loop, one macOS focus steal per iteration (30/day on reddit-harness).
|
|
17
|
+
Both revive paths need websocket-client (present in the owned runtime venv,
|
|
18
|
+
S4L_PYTHON); without it the script degrades to close-only, as before.
|
|
9
19
|
|
|
10
20
|
The standalone-script form (vs an inline heredoc) is required because bash
|
|
11
21
|
3.2 on macOS cannot parse a nested heredoc inside a function body inside a
|
|
@@ -24,6 +34,56 @@ import urllib.request
|
|
|
24
34
|
# its own harness Chrome on 9556. Default 9555 keeps Twitter callers unchanged.
|
|
25
35
|
CDP_PORT = int(os.environ.get("BH_CLEANUP_PORT", "9555"))
|
|
26
36
|
CDP_URL = f"http://127.0.0.1:{CDP_PORT}"
|
|
37
|
+
REVIVE_URL = os.environ.get("BH_REVIVE_URL", "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_real(t) -> bool:
|
|
41
|
+
return (t.get("url") or "").startswith(("http://", "https://"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ws_call(ws_url: str, method: str, timeout: float = 8.0, **params):
|
|
45
|
+
# suppress_origin: Chrome 111+ rejects ws clients whose Origin header is
|
|
46
|
+
# not in --remote-allow-origins (same pattern as cdp_ready_check.py).
|
|
47
|
+
import websocket # websocket-client; ImportError handled by callers
|
|
48
|
+
|
|
49
|
+
ws = websocket.create_connection(ws_url, timeout=timeout, suppress_origin=True)
|
|
50
|
+
try:
|
|
51
|
+
ws.send(json.dumps({"id": 1, "method": method, "params": params}))
|
|
52
|
+
import time
|
|
53
|
+
|
|
54
|
+
deadline = time.time() + timeout
|
|
55
|
+
while time.time() < deadline:
|
|
56
|
+
msg = json.loads(ws.recv())
|
|
57
|
+
if msg.get("id") == 1:
|
|
58
|
+
return msg
|
|
59
|
+
raise TimeoutError(f"no reply to {method}")
|
|
60
|
+
finally:
|
|
61
|
+
ws.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _revive(keep) -> None:
|
|
65
|
+
"""Ensure one REAL tab exists: navigate a lone non-real tab (keep) to
|
|
66
|
+
REVIVE_URL, or create a background tab there when keep is None (tabless
|
|
67
|
+
Chrome). Best-effort; cleanup's exit code never depends on it."""
|
|
68
|
+
if not REVIVE_URL.startswith(("http://", "https://")):
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
if keep is None:
|
|
72
|
+
# background=True: a foreground Target.createTarget raises the
|
|
73
|
+
# Chrome window and steals macOS app focus; revive must be silent.
|
|
74
|
+
info = json.loads(
|
|
75
|
+
urllib.request.urlopen(f"{CDP_URL}/json/version", timeout=2).read()
|
|
76
|
+
)
|
|
77
|
+
_ws_call(
|
|
78
|
+
info["webSocketDebuggerUrl"], "Target.createTarget",
|
|
79
|
+
url=REVIVE_URL, background=True,
|
|
80
|
+
)
|
|
81
|
+
print(f"[cleanup_harness_tabs] created background tab on {REVIVE_URL}")
|
|
82
|
+
elif not _is_real(keep) and keep.get("webSocketDebuggerUrl"):
|
|
83
|
+
_ws_call(keep["webSocketDebuggerUrl"], "Page.navigate", url=REVIVE_URL)
|
|
84
|
+
print(f"[cleanup_harness_tabs] revived blank tab -> {REVIVE_URL}")
|
|
85
|
+
except Exception as e:
|
|
86
|
+
print(f"[cleanup_harness_tabs] revive skipped ({type(e).__name__}: {e})")
|
|
27
87
|
|
|
28
88
|
|
|
29
89
|
def main() -> int:
|
|
@@ -33,8 +93,13 @@ def main() -> int:
|
|
|
33
93
|
except Exception:
|
|
34
94
|
return 0
|
|
35
95
|
pages = [t for t in tabs if t.get("type") == "page"]
|
|
36
|
-
if
|
|
37
|
-
print(
|
|
96
|
+
if not pages:
|
|
97
|
+
print("[cleanup_harness_tabs] 0 page tab(s)")
|
|
98
|
+
_revive(None)
|
|
99
|
+
return 0
|
|
100
|
+
if len(pages) == 1:
|
|
101
|
+
print("[cleanup_harness_tabs] 1 page tab(s), no cleanup needed")
|
|
102
|
+
_revive(pages[0])
|
|
38
103
|
return 0
|
|
39
104
|
# Keep a REAL (http/https) tab when one exists, not blindly pages[0]. The
|
|
40
105
|
# /json order is roughly most-recently-active first, so a freshly-spawned
|
|
@@ -43,9 +108,6 @@ def main() -> int:
|
|
|
43
108
|
# daemon's tab forces it to re-attach and re-spawn another about:blank, which
|
|
44
109
|
# is exactly the orphan-tab churn this script is meant to clean up. Falling
|
|
45
110
|
# back to pages[0] preserves the prior behavior when every tab is blank.
|
|
46
|
-
def _is_real(t):
|
|
47
|
-
return (t.get("url") or "").startswith(("http://", "https://"))
|
|
48
|
-
|
|
49
111
|
keep = next((t for t in pages if _is_real(t)), pages[0])
|
|
50
112
|
closed = 0
|
|
51
113
|
for t in pages:
|
|
@@ -61,6 +123,7 @@ def main() -> int:
|
|
|
61
123
|
pass
|
|
62
124
|
kept_kind = "1 real" if _is_real(keep) else "1"
|
|
63
125
|
print(f"[cleanup_harness_tabs] closed {closed}/{len(pages) - 1} extra page tabs (kept {kept_kind})")
|
|
126
|
+
_revive(keep)
|
|
64
127
|
return 0
|
|
65
128
|
|
|
66
129
|
|