@m13v/s4l 1.7.6 → 1.7.7-rc.10
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/bin/cli.js +12 -0
- package/mcp/dist/index.js +25 -70
- package/mcp/dist/repo.js +15 -1
- package/mcp/dist/runtime.js +103 -55
- package/mcp/dist/version.js +49 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +54 -0
- package/mcp/menubar/s4l_card.py +19 -20
- package/mcp/menubar/s4l_card_canvas.py +29 -26
- package/mcp/menubar/s4l_menubar.py +38 -0
- package/mcp/menubar/s4l_state.py +118 -5
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_pa_compact.py +15 -0
- package/scripts/_pa_q.py +14 -0
- package/scripts/_pa_run.py +20 -0
- package/scripts/browser_lifecycle.py +48 -0
- package/scripts/claude_job.py +14 -0
- package/scripts/cleanup_harness_tabs.py +74 -11
- package/scripts/context_mining.py +664 -0
- package/scripts/copy_browser_cookies.py +3 -1
- package/scripts/harness_target_monitor.py +105 -0
- package/scripts/linkedin_session_watch.py +196 -0
- package/scripts/merge_review_queue.py +3 -1
- package/scripts/patches/browser-harness-daemon-blank-tab-attach.patch +30 -0
- package/scripts/post_reddit.py +1 -1
- package/scripts/reddit_ban_check.py +19 -1
- package/scripts/reddit_browser.py +9 -3
- package/scripts/reddit_browser_fetch.py +5 -1
- package/scripts/reddit_tools.py +50 -98
- package/scripts/s4l_box_update.sh +35 -5
- package/scripts/snapshot.py +53 -2
- package/scripts/stats.py +26 -53
- package/scripts/tlh_freshness_284.py +134 -0
- package/scripts/twitter_browser.py +208 -27
- 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/bin/cli.js
CHANGED
|
@@ -456,6 +456,18 @@ function installBrowserHarness() {
|
|
|
456
456
|
label: 'focus-suppression + tab-event observability',
|
|
457
457
|
absentWarn: 'harness Chrome may steal OS focus on every tab switch',
|
|
458
458
|
},
|
|
459
|
+
{
|
|
460
|
+
// Daemon attach_first_page: reuse an existing blank tab and create in
|
|
461
|
+
// the BACKGROUND when truly tabless. Without it, a blank-only browser
|
|
462
|
+
// traps the daemon in a stale-session loop where every re-attach mints
|
|
463
|
+
// a foreground about:blank = one macOS focus steal per iteration
|
|
464
|
+
// (2026-08-03, 30+/day on the reddit harness).
|
|
465
|
+
file: 'browser-harness-daemon-blank-tab-attach.patch',
|
|
466
|
+
sentinelFile: path.join('src', 'browser_harness', 'daemon.py'),
|
|
467
|
+
sentinelStr: 'no page tabs at all',
|
|
468
|
+
label: 'daemon blank-tab background attach',
|
|
469
|
+
absentWarn: 'harness daemon re-attach may pop the Chrome window (focus steal)',
|
|
470
|
+
},
|
|
459
471
|
];
|
|
460
472
|
for (const p of vendoredPatches) {
|
|
461
473
|
const patchPath = path.join(PKG_ROOT, 'scripts', 'patches', p.file);
|
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/repo.js
CHANGED
|
@@ -71,6 +71,18 @@ export function run(cmd, args, opts = {}) {
|
|
|
71
71
|
catch {
|
|
72
72
|
/* a spawn observer must never break the run */
|
|
73
73
|
}
|
|
74
|
+
if (opts.stdin != null) {
|
|
75
|
+
// Swallow async pipe errors (e.g. EPIPE when the child exits before
|
|
76
|
+
// reading) — an unhandled stream error would crash the whole process.
|
|
77
|
+
child.stdin.on("error", () => { });
|
|
78
|
+
try {
|
|
79
|
+
child.stdin.write(opts.stdin);
|
|
80
|
+
child.stdin.end();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* a closed stdin must never break the run */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
74
86
|
let stdout = "";
|
|
75
87
|
let stderr = "";
|
|
76
88
|
// Per-stream partial-line buffers so onLine fires on whole lines only,
|
|
@@ -199,7 +211,9 @@ export function readPlan(batchId) {
|
|
|
199
211
|
}
|
|
200
212
|
}
|
|
201
213
|
export function writePlan(batchId, plan) {
|
|
202
|
-
|
|
214
|
+
// Compact (no indent): the review store reached 65 MB with pretty-printing
|
|
215
|
+
// (2026-08-02 lag incident) and every reader pays the parse.
|
|
216
|
+
fs.writeFileSync(planPath(batchId), JSON.stringify(plan), "utf-8");
|
|
203
217
|
}
|
|
204
218
|
// Find the newest plan file when no batch id is supplied.
|
|
205
219
|
export function latestBatchId() {
|
package/mcp/dist/runtime.js
CHANGED
|
@@ -34,6 +34,82 @@ const BROWSER_HARNESS_PIN = "6d20866664ea3d9691b27bbf64f42ae097437dc3";
|
|
|
34
34
|
const BROWSER_HARNESS_REPO = "https://github.com/browser-use/browser-harness";
|
|
35
35
|
const HARNESS_DIR = path.join(os.homedir(), "Developer", "browser-harness");
|
|
36
36
|
const HARNESS_BIN = path.join(os.homedir(), ".local", "bin", "browser-harness");
|
|
37
|
+
// Vendored fixes applied on top of the pin (we don't control upstream).
|
|
38
|
+
// KEEP IN SYNC WITH bin/cli.js `vendoredPatches`. Each: patch file under
|
|
39
|
+
// scripts/patches/ + a sentinel (file, substring) that means the fix is
|
|
40
|
+
// already present (upstream merged it, or we applied it), so a non-applying
|
|
41
|
+
// patch only warns when the fix is genuinely absent. Order matters: patches
|
|
42
|
+
// are generated to apply sequentially onto the pristine pin (each also
|
|
43
|
+
// applies standalone, verified 2026-08-03). NEVER fail install over these.
|
|
44
|
+
const HARNESS_VENDORED_PATCHES = [
|
|
45
|
+
{
|
|
46
|
+
file: "browser-harness-loopback-cdp-proxy.patch",
|
|
47
|
+
sentinelFile: path.join("src", "browser_harness", "admin.py"),
|
|
48
|
+
sentinelStr: "cdp_urlopen",
|
|
49
|
+
label: "loopback-proxy",
|
|
50
|
+
absentWarn: "CDP probes may 403 behind a system proxy",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
// Suppress OS-focus-stealing Target.activateTarget on offscreen automation
|
|
54
|
+
// harnesses; [bh_tab_event] observability. Was previously applied only by
|
|
55
|
+
// the npm installer (bin/cli.js) — a bare .mcpb provision skipped it, so
|
|
56
|
+
// mcpb-only boxes ran upstream helpers that steal focus on every tab
|
|
57
|
+
// switch (gap closed 2026-08-03).
|
|
58
|
+
file: "browser-harness-focus-and-observability.patch",
|
|
59
|
+
sentinelFile: path.join("src", "browser_harness", "helpers.py"),
|
|
60
|
+
sentinelStr: "_is_offscreen_harness",
|
|
61
|
+
label: "focus-suppression + tab-event observability",
|
|
62
|
+
absentWarn: "harness Chrome may steal OS focus on every tab switch",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
// Daemon attach_first_page: reuse an existing blank tab and create in the
|
|
66
|
+
// BACKGROUND when truly tabless. Without it, a blank-only browser traps
|
|
67
|
+
// the daemon in a stale-session loop where every re-attach mints a
|
|
68
|
+
// foreground about:blank = one macOS focus steal per iteration.
|
|
69
|
+
file: "browser-harness-daemon-blank-tab-attach.patch",
|
|
70
|
+
sentinelFile: path.join("src", "browser_harness", "daemon.py"),
|
|
71
|
+
sentinelStr: "no page tabs at all",
|
|
72
|
+
label: "daemon blank-tab background attach",
|
|
73
|
+
absentWarn: "harness daemon re-attach may pop the Chrome window (focus steal)",
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
/** Apply every vendored patch to HARNESS_DIR (check-then-apply, sentinel-aware).
|
|
77
|
+
* Returns a human-readable summary; never throws. */
|
|
78
|
+
async function applyHarnessVendoredPatches() {
|
|
79
|
+
const results = [];
|
|
80
|
+
for (const p of HARNESS_VENDORED_PATCHES) {
|
|
81
|
+
const patchPath = path.join(MATERIALIZED_REPO, "scripts", "patches", p.file);
|
|
82
|
+
if (!fs.existsSync(patchPath)) {
|
|
83
|
+
results.push(`${p.label}: patch file missing`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const chk = await sh("git", ["-C", HARNESS_DIR, "apply", "--check", patchPath], {
|
|
87
|
+
timeoutMs: 30000,
|
|
88
|
+
});
|
|
89
|
+
if (chk.code === 0) {
|
|
90
|
+
const app = await sh("git", ["-C", HARNESS_DIR, "apply", patchPath], { timeoutMs: 30000 });
|
|
91
|
+
results.push(`${p.label}: ${app.code === 0 ? "applied" : `apply failed (${app.code})`}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
let present = false;
|
|
95
|
+
try {
|
|
96
|
+
present = fs
|
|
97
|
+
.readFileSync(path.join(HARNESS_DIR, p.sentinelFile), "utf-8")
|
|
98
|
+
.includes(p.sentinelStr);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* file moved — fall through to the warning */
|
|
102
|
+
}
|
|
103
|
+
if (present) {
|
|
104
|
+
results.push(`${p.label}: already present`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
console.error(`[runtime] browser-harness ${p.label} patch no longer applies and the fix is absent upstream; ${p.absentWarn}`);
|
|
108
|
+
results.push(`${p.label}: ABSENT (patch stale)`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return results.join("; ");
|
|
112
|
+
}
|
|
37
113
|
// The harness drives a REAL Google Chrome over CDP (see twitter-backend.sh
|
|
38
114
|
// _resolve_chrome_bin). Nothing installs Chrome, the runtime only ever
|
|
39
115
|
// downloaded Playwright's Chromium (which the cycle does NOT use), so a .mcpb
|
|
@@ -733,40 +809,37 @@ export async function ensureHarnessPatched() {
|
|
|
733
809
|
// installing) rather than racing on the same checkout.
|
|
734
810
|
if (anotherProcessProvisioning())
|
|
735
811
|
return { ok: false, detail: "provisioning in progress elsewhere" };
|
|
736
|
-
// Version
|
|
737
|
-
// in its newest revision (
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
// previous revision.
|
|
812
|
+
// Version markers of the CURRENT vendored patches. A patch revision's
|
|
813
|
+
// marker is a symbol that exists only in its newest revision (the proxy
|
|
814
|
+
// patch's v2 added the daemon.py websocket-proxy neutralization —
|
|
815
|
+
// s4l_no_proxy_ws — after v1's cdp_urlopen alone proved insufficient).
|
|
816
|
+
// A NEW fix gets a NEW patch file + sentinel (see HARNESS_VENDORED_PATCHES)
|
|
817
|
+
// rather than new hunks in an old patch, or upgraded installs whose old
|
|
818
|
+
// sentinel still matches would silently keep the previous revision.
|
|
743
819
|
const daemonPy = path.join(HARNESS_DIR, "src", "browser_harness", "daemon.py");
|
|
744
820
|
if (!fs.existsSync(daemonPy))
|
|
745
821
|
return { ok: false, detail: "harness not installed yet" };
|
|
746
|
-
|
|
822
|
+
const daemonSrc = fs.readFileSync(daemonPy, "utf-8");
|
|
823
|
+
const helpersPy = path.join(HARNESS_DIR, "src", "browser_harness", "helpers.py");
|
|
824
|
+
const helpersSrc = fs.existsSync(helpersPy) ? fs.readFileSync(helpersPy, "utf-8") : "";
|
|
825
|
+
if (daemonSrc.includes("s4l_no_proxy_ws") &&
|
|
826
|
+
daemonSrc.includes("no page tabs at all") &&
|
|
827
|
+
helpersSrc.includes("_is_offscreen_harness")) {
|
|
747
828
|
return { ok: true, detail: "already patched" };
|
|
748
829
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
830
|
+
// Discard any PRIOR vendored-patch revision so the patch set applies onto
|
|
831
|
+
// pristine pinned sources, then re-apply ALL vendored patches (the old
|
|
832
|
+
// single-patch flow re-applied only the proxy fix here, silently wiping
|
|
833
|
+
// the focus + daemon patches whenever this boot-repair fired). Only src/
|
|
834
|
+
// is touched; the checkout is a managed artifact, never a place for
|
|
835
|
+
// local edits.
|
|
755
836
|
await sh("git", ["-C", HARNESS_DIR, "checkout", "--", "src/browser_harness"], {
|
|
756
837
|
timeoutMs: 30000,
|
|
757
838
|
});
|
|
758
|
-
const
|
|
759
|
-
timeoutMs: 30000,
|
|
760
|
-
});
|
|
761
|
-
if (chk.code !== 0) {
|
|
762
|
-
return { ok: false, detail: "patch does not apply (dirty or diverged checkout)" };
|
|
763
|
-
}
|
|
764
|
-
const app = await sh("git", ["-C", HARNESS_DIR, "apply", harnessPatch], { timeoutMs: 30000 });
|
|
765
|
-
if (app.code !== 0)
|
|
766
|
-
return { ok: false, detail: `git apply failed (exit ${app.code})` };
|
|
839
|
+
const summary = await applyHarnessVendoredPatches();
|
|
767
840
|
// Reload the daemon so the long-lived process re-imports the patched source.
|
|
768
841
|
await sh(HARNESS_BIN, ["--reload"], { timeoutMs: 30000 });
|
|
769
|
-
return { ok: true, detail:
|
|
842
|
+
return { ok: true, detail: `patched + daemon reloaded (${summary})` };
|
|
770
843
|
}
|
|
771
844
|
catch (e) {
|
|
772
845
|
return { ok: false, detail: String(e?.message || e) };
|
|
@@ -1011,37 +1084,12 @@ async function provision(progress) {
|
|
|
1011
1084
|
timeoutMs: 120000,
|
|
1012
1085
|
});
|
|
1013
1086
|
await sh("git", ["-C", HARNESS_DIR, "reset", "--hard", "FETCH_HEAD"], { timeoutMs: 60000 });
|
|
1014
|
-
// Vendored
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
|
|
1020
|
-
// cleanly, upstream has either merged the fix (detected below; fine) or
|
|
1021
|
-
// refactored the files (warn); never fail the install over it.
|
|
1022
|
-
const harnessPatch = path.join(MATERIALIZED_REPO, "scripts", "patches", "browser-harness-loopback-cdp-proxy.patch");
|
|
1023
|
-
if (fs.existsSync(harnessPatch)) {
|
|
1024
|
-
const chk = await sh("git", ["-C", HARNESS_DIR, "apply", "--check", harnessPatch], {
|
|
1025
|
-
timeoutMs: 30000,
|
|
1026
|
-
});
|
|
1027
|
-
if (chk.code === 0) {
|
|
1028
|
-
await sh("git", ["-C", HARNESS_DIR, "apply", harnessPatch], { timeoutMs: 30000 });
|
|
1029
|
-
}
|
|
1030
|
-
else {
|
|
1031
|
-
let fixedUpstream = false;
|
|
1032
|
-
try {
|
|
1033
|
-
fixedUpstream = fs
|
|
1034
|
-
.readFileSync(path.join(HARNESS_DIR, "src", "browser_harness", "admin.py"), "utf-8")
|
|
1035
|
-
.includes("cdp_urlopen");
|
|
1036
|
-
}
|
|
1037
|
-
catch {
|
|
1038
|
-
/* file moved — fall through to the warning */
|
|
1039
|
-
}
|
|
1040
|
-
if (!fixedUpstream) {
|
|
1041
|
-
console.error("[runtime] browser-harness loopback-proxy patch no longer applies and the fix is absent upstream; CDP probes may 403 behind a system proxy");
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1087
|
+
// Vendored fixes on top of the pin (loopback-proxy, focus-suppression,
|
|
1088
|
+
// daemon blank-tab attach — see HARNESS_VENDORED_PATCHES). Applied at
|
|
1089
|
+
// install time on every machine since we don't control upstream. This
|
|
1090
|
+
// previously applied ONLY the proxy patch, so bare .mcpb boxes ran
|
|
1091
|
+
// upstream helpers/daemon that steal OS focus (gap closed 2026-08-03).
|
|
1092
|
+
await applyHarnessVendoredPatches();
|
|
1045
1093
|
// Install the CLI via uv tool (lands at ~/.local/bin/browser-harness).
|
|
1046
1094
|
// --force so a refreshed source / changed entry point is reinstalled.
|
|
1047
1095
|
const inst = await sh(uv, ["tool", "install", "--force", "-e", HARNESS_DIR], {
|
package/mcp/dist/version.js
CHANGED
|
@@ -195,16 +195,63 @@ async function latestFromGithubRedirect() {
|
|
|
195
195
|
return null;
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
|
+
// Optional GitHub token (2026-07-30): authenticated probes get 5000/h instead
|
|
199
|
+
// of the anonymous 60/h-per-IP quota that silenced the staging update banner
|
|
200
|
+
// on 2026-07-13 and again on 2026-07-30. Sources, in order: GITHUB_TOKEN /
|
|
201
|
+
// GH_TOKEN env, then `gh auth token` when the gh CLI exists (dev/operator
|
|
202
|
+
// machines; .mcpb boxes have neither and resolve to null instantly). The token
|
|
203
|
+
// is only ever sent to api.github.com, always via stdin (curl -H @-) so it
|
|
204
|
+
// never appears in argv/`ps`, and the `gh auth token` shell-out is noTee so it
|
|
205
|
+
// never reaches the telemetry relay. Keep in lockstep with
|
|
206
|
+
// scripts/snapshot.py::_github_token.
|
|
207
|
+
let ghTokCache = { at: 0, tok: null };
|
|
208
|
+
const GH_TOK_TTL_MS = 900_000;
|
|
209
|
+
async function githubToken() {
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
if (ghTokCache.at && now - ghTokCache.at < GH_TOK_TTL_MS)
|
|
212
|
+
return ghTokCache.tok;
|
|
213
|
+
let tok = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null;
|
|
214
|
+
if (!tok) {
|
|
215
|
+
for (const gh of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "gh"]) {
|
|
216
|
+
const res = await run(gh, ["auth", "token"], { timeoutMs: 5000, noTee: true });
|
|
217
|
+
if (res.code === -1)
|
|
218
|
+
continue; // not spawnable at this path; try the next
|
|
219
|
+
const cand = (res.stdout || "").trim();
|
|
220
|
+
if (res.code === 0 && cand)
|
|
221
|
+
tok = cand;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
ghTokCache = { at: now, tok };
|
|
226
|
+
return tok;
|
|
227
|
+
}
|
|
198
228
|
// Conditional-request state lives in the SHARED cache file (latest-release.json)
|
|
199
229
|
// so the ETag survives process boundaries: short-lived MCP respawns used to pay
|
|
200
230
|
// a full 200 per process; now every probe sends If-None-Match and gets a free
|
|
201
|
-
// 304 between releases.
|
|
231
|
+
// 304 between releases. Probes authenticate when a GitHub token is available;
|
|
232
|
+
// a 401 (revoked/expired token) retries anonymously so a bad token is never
|
|
233
|
+
// worse than no token.
|
|
202
234
|
async function curlConditional(url, etag) {
|
|
235
|
+
const tok = await githubToken();
|
|
236
|
+
const first = await curlOnce(url, etag, tok);
|
|
237
|
+
if (first.status === 401 && tok) {
|
|
238
|
+
ghTokCache = { at: Date.now(), tok: null }; // drop the dead token
|
|
239
|
+
return curlOnce(url, etag, null);
|
|
240
|
+
}
|
|
241
|
+
return first;
|
|
242
|
+
}
|
|
243
|
+
async function curlOnce(url, etag, token) {
|
|
203
244
|
const args = ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json"];
|
|
245
|
+
if (token)
|
|
246
|
+
args.push("-H", "@-"); // Authorization arrives via stdin, never argv
|
|
204
247
|
if (etag)
|
|
205
248
|
args.push("-H", `If-None-Match: ${etag}`);
|
|
206
249
|
args.push("-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url);
|
|
207
|
-
const res = await run("curl", args, {
|
|
250
|
+
const res = await run("curl", args, {
|
|
251
|
+
timeoutMs: 12000,
|
|
252
|
+
noTee: true,
|
|
253
|
+
stdin: token ? `Authorization: Bearer ${token}` : undefined,
|
|
254
|
+
});
|
|
208
255
|
let status = 0;
|
|
209
256
|
let newEtag = null;
|
|
210
257
|
const body = [];
|
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.
|
|
5
|
+
"version": "1.7.7-rc.10",
|
|
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": {
|
|
@@ -79,6 +79,7 @@ class _Worker(threading.Thread):
|
|
|
79
79
|
super().__init__(daemon=True, name="s4l-browser-foreground")
|
|
80
80
|
self._pid_cache = {} # pid -> (is_harness, details dict)
|
|
81
81
|
self._prev_app = None # last non-harness frontmost app name
|
|
82
|
+
self._prev_app_pid = None # ...and its pid, for precise re-activation
|
|
82
83
|
self._last_key = None # (cause, pid) of last emitted event
|
|
83
84
|
self._last_emit_at = 0.0
|
|
84
85
|
self._suppressed = 0
|
|
@@ -108,13 +109,19 @@ class _Worker(threading.Thread):
|
|
|
108
109
|
if "chrome" not in low and "chromium" not in low:
|
|
109
110
|
if cause == "activated" and name:
|
|
110
111
|
self._prev_app = name
|
|
112
|
+
self._prev_app_pid = pid
|
|
111
113
|
return
|
|
112
114
|
is_harness, details = self._classify(pid)
|
|
113
115
|
if not is_harness:
|
|
114
116
|
# The user's own Chrome counts as their workspace too.
|
|
115
117
|
if cause == "activated" and name:
|
|
116
118
|
self._prev_app = name
|
|
119
|
+
self._prev_app_pid = pid
|
|
117
120
|
return
|
|
121
|
+
# Suppress focus on EVERY harness activation, BEFORE the telemetry
|
|
122
|
+
# dedupe — the log is rate-limited, but hiding must fire every time or
|
|
123
|
+
# a fast pop-burst would leave later pops sitting in the foreground.
|
|
124
|
+
self._suppress_focus(pid, details)
|
|
118
125
|
now = time.time()
|
|
119
126
|
key = (cause, pid)
|
|
120
127
|
if key == self._last_key and now - self._last_emit_at < _DEDUPE_SECONDS:
|
|
@@ -137,6 +144,53 @@ class _Worker(threading.Thread):
|
|
|
137
144
|
context="browser-foreground",
|
|
138
145
|
)
|
|
139
146
|
|
|
147
|
+
def _suppress_focus(self, pid, details):
|
|
148
|
+
# OS-LEVEL FOCUS SUPPRESSION (2026-07-30). The per-path code fixes
|
|
149
|
+
# (bh-harness activate suppression, tab reuse/park) each cover ONE way
|
|
150
|
+
# of driving Chrome; the reddit pipeline reaches it via a DIFFERENT
|
|
151
|
+
# path (reddit_browser.py Playwright connect/new_page) that bypasses
|
|
152
|
+
# them, and a raw external Playwright connect bypasses everything. This
|
|
153
|
+
# acts on the OS window event itself, so it covers EVERY connector:
|
|
154
|
+
# when an OFFSCREEN automation harness (window parked at negative Y —
|
|
155
|
+
# twitter/reddit/linkedin; NOT the onscreen setup-login window) grabs
|
|
156
|
+
# the foreground, hide that specific Chrome process by pid, then force
|
|
157
|
+
# focus back to the app the user was in. Screenshots/clicks are
|
|
158
|
+
# unaffected (CDP is offscreen-raster + synthetic input; the occlusion
|
|
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.
|
|
163
|
+
if os.environ.get("S4L_NO_HARNESS_HIDE"):
|
|
164
|
+
return
|
|
165
|
+
if os.environ.get("S4L_HARNESS_HIDE") != "1":
|
|
166
|
+
return
|
|
167
|
+
pos = details.get("window_position") or ""
|
|
168
|
+
try:
|
|
169
|
+
offscreen = any(int(float(v)) < 0 for v in pos.split(",")[:2])
|
|
170
|
+
except Exception:
|
|
171
|
+
offscreen = False
|
|
172
|
+
if not offscreen:
|
|
173
|
+
return # onscreen (e.g. setup login) — leave it visible
|
|
174
|
+
try:
|
|
175
|
+
from AppKit import NSRunningApplication
|
|
176
|
+
|
|
177
|
+
ra = NSRunningApplication.runningApplicationWithProcessIdentifier_(pid)
|
|
178
|
+
if ra is not None:
|
|
179
|
+
ra.hide() # order the harness window out
|
|
180
|
+
# hide() alone does NOT reliably return focus here: all three
|
|
181
|
+
# harnesses share the "Google Chrome Beta" bundle, so hiding one
|
|
182
|
+
# process can leave the app-level active state on a sibling. Force
|
|
183
|
+
# focus back to the app the user was actually in.
|
|
184
|
+
if self._prev_app_pid and self._prev_app_pid != pid:
|
|
185
|
+
prev = NSRunningApplication.runningApplicationWithProcessIdentifier_(
|
|
186
|
+
self._prev_app_pid
|
|
187
|
+
)
|
|
188
|
+
# NSApplicationActivateIgnoringOtherApps = 1 << 1
|
|
189
|
+
if prev is not None and not prev.isTerminated():
|
|
190
|
+
prev.activateWithOptions_(1 << 1)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
140
194
|
def run(self):
|
|
141
195
|
while True:
|
|
142
196
|
try:
|
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)
|
|
@@ -1973,7 +1964,12 @@ class _ReviewController(NSObject):
|
|
|
1973
1964
|
"""NSTimer target (2026-07-15): re-renders the header's age/expiry
|
|
1974
1965
|
label every second so its countdown visibly counts down without
|
|
1975
1966
|
needing hover. Not a python_method -- NSTimer invokes this through
|
|
1976
|
-
the ObjC runtime.
|
|
1967
|
+
the ObjC runtime. No-op when the rendered text is unchanged: the
|
|
1968
|
+
label usually shows a coarse "3h"-style value that only changes
|
|
1969
|
+
every few minutes, and unconditionally re-styling it dirtied one
|
|
1970
|
+
layer per tile per second -- with a 130-tile canvas that was a
|
|
1971
|
+
constant CoreAnimation commit churn keeping the app at ~15% CPU
|
|
1972
|
+
while idle (2026-08-02 lag incident)."""
|
|
1977
1973
|
if self._age_expiry_label is None:
|
|
1978
1974
|
return
|
|
1979
1975
|
try:
|
|
@@ -1984,6 +1980,9 @@ class _ReviewController(NSObject):
|
|
|
1984
1980
|
)
|
|
1985
1981
|
if not text:
|
|
1986
1982
|
return
|
|
1983
|
+
if (text, urgent) == getattr(self, "_age_expiry_last", None):
|
|
1984
|
+
return
|
|
1985
|
+
self._age_expiry_last = (text, urgent)
|
|
1987
1986
|
self._age_expiry_label.setStringValue_(text)
|
|
1988
1987
|
self._age_expiry_label.setFont_(_font(11, urgent))
|
|
1989
1988
|
self._age_expiry_label.setTextColor_(
|