@m13v/s4l 1.7.2 → 1.7.4-rc.1
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 +93 -8
- package/mcp/dist/screencast.js +27 -0
- package/mcp/dist/setup.js +8 -2
- package/mcp/dist/twitterAuth.js +3 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +188 -0
- package/mcp/menubar/s4l_card.py +138 -37
- package/mcp/menubar/s4l_log_relay.py +32 -0
- package/mcp/menubar/s4l_menubar.py +20 -7
- package/mcp/menubar/s4l_state.py +37 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/build_persona.py +8 -3
- package/scripts/engagement_styles.py +12 -1
- package/scripts/invent_styles.py +245 -0
- package/scripts/merge_review_queue.py +24 -4
- package/scripts/pick_project.py +12 -3
- package/scripts/recent_self_posts.py +146 -0
- package/scripts/release-mcpb.sh +15 -1
- package/scripts/s4l_mode.py +9 -8
- package/scripts/scan_x_profile.py +143 -11
- package/scripts/tlh_pick_248.py +92 -0
- package/scripts/top_performers.py +55 -9
- package/scripts/twitter_post_plan.py +67 -0
- package/scripts/voice_exemplars.py +424 -0
- package/skill/run-draft-and-publish.sh +4 -3
- package/skill/run-twitter-cycle.sh +24 -4
package/mcp/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import os from "node:os";
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
23
|
-
import { applySetup, resolveProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
|
|
23
|
+
import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, normalizeStringList, } from "./setup.js";
|
|
24
24
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
25
25
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, retryProvisionIfStalled, } from "./runtime.js";
|
|
26
26
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
@@ -1023,7 +1023,12 @@ async function postApproved(batchId, plan) {
|
|
|
1023
1023
|
};
|
|
1024
1024
|
}
|
|
1025
1025
|
return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
|
|
1026
|
-
|
|
1026
|
+
// Scale with batch size: a mass-approval drain runs ~15-20s per card,
|
|
1027
|
+
// so a fixed 15min ceiling SIGTERMed any batch over ~50 cards mid-post
|
|
1028
|
+
// (Karol 2026-07-09: 0/131 posted, exit=-1). 60s/card headroom covers
|
|
1029
|
+
// slow candidates; the 2h cap bounds a hung poster (the browser-lock
|
|
1030
|
+
// expiry and per-reply subprocess timeouts still fire underneath).
|
|
1031
|
+
timeoutMs: Math.min(7_200_000, Math.max(900_000, approved.length * 60_000)),
|
|
1027
1032
|
env: ({
|
|
1028
1033
|
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
1029
1034
|
// Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
|
|
@@ -1389,6 +1394,31 @@ async function seedSearchQueriesForProject(project, rawQueries) {
|
|
|
1389
1394
|
return { note: ` (Search-query seeding skipped — ${e.message}.)`, queries };
|
|
1390
1395
|
}
|
|
1391
1396
|
}
|
|
1397
|
+
// After a project save, persist the profile scan's engagement-ranked top
|
|
1398
|
+
// replies into that project's voice.examples (and the persona_corpus.txt
|
|
1399
|
+
// exemplar section when the project is the persona). Every drafting prompt on
|
|
1400
|
+
// every platform already mirrors voice.examples, so this ONE write feeds them
|
|
1401
|
+
// all. scripts/voice_exemplars.py reads the last_profile_scan.json sidecar
|
|
1402
|
+
// scan_x_profile.py wrote and only quotes the user's own public replies
|
|
1403
|
+
// verbatim (mechanical ranking, no synthesis). Best-effort by design: no scan
|
|
1404
|
+
// yet, no usable replies, or hand-written voice.examples (exit 3, respected)
|
|
1405
|
+
// all return null and never block the save.
|
|
1406
|
+
async function applyScannedVoiceExamples(project) {
|
|
1407
|
+
try {
|
|
1408
|
+
const res = await runPython("scripts/voice_exemplars.py", ["apply", "--project", project], {
|
|
1409
|
+
timeoutMs: 30_000,
|
|
1410
|
+
});
|
|
1411
|
+
const last = res.stdout.trim().split("\n").slice(-1)[0] || "";
|
|
1412
|
+
const parsed = JSON.parse(last);
|
|
1413
|
+
if (parsed.ok && parsed.voice_examples_written) {
|
|
1414
|
+
return `Stored ${parsed.voice_examples_written} of their top-performing real replies (ranked by engagement, with the threads they answered) as voice.examples — every drafter now mirrors them.`;
|
|
1415
|
+
}
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1392
1422
|
// ---- engagement_mode: choose personal-brand vs product (setup-time) --------
|
|
1393
1423
|
// Part of onboarding: AFTER X connect + profile_scan, BEFORE product config, the
|
|
1394
1424
|
// agent asks the user which mode they want and calls this. It persists the mode
|
|
@@ -1598,6 +1628,10 @@ tool("engagement_mode", {
|
|
|
1598
1628
|
return textContent(`Mode saved as ${mode}, but provisioning the persona project failed: ${e?.message || e}. ` +
|
|
1599
1629
|
`Retry engagement_mode action:'set'.`);
|
|
1600
1630
|
}
|
|
1631
|
+
// Persist the profile scan's top-performing real replies as the persona's
|
|
1632
|
+
// voice.examples + the persona_corpus.txt exemplar section (best-effort;
|
|
1633
|
+
// see applyScannedVoiceExamples).
|
|
1634
|
+
const personaExemplarNote = await applyScannedVoiceExamples(personaName);
|
|
1601
1635
|
// Seed the persona's topics into the DB universe the cycle reads (best-effort;
|
|
1602
1636
|
// the cycle's own fail-loud path still reports if topics are missing).
|
|
1603
1637
|
let personaTopicsSeeded = false;
|
|
@@ -1682,6 +1716,7 @@ tool("engagement_mode", {
|
|
|
1682
1716
|
persona_topic_count: personaTopicCount,
|
|
1683
1717
|
persona_query_count: personaQueryCount,
|
|
1684
1718
|
persona_query_note: personaQueryNote || null,
|
|
1719
|
+
persona_voice_examples: personaExemplarNote,
|
|
1685
1720
|
kicker_installed: kickerInstall ? kickerInstall.ok : null,
|
|
1686
1721
|
kicker_detail: kickerInstall ? kickerInstall.detail : null,
|
|
1687
1722
|
onboarding: onboardingSnapshot(),
|
|
@@ -1961,12 +1996,17 @@ tool("project_config", {
|
|
|
1961
1996
|
counts: scan.counts,
|
|
1962
1997
|
posts: scan.posts,
|
|
1963
1998
|
comments: scan.comments,
|
|
1999
|
+
top_posts: scan.top_posts,
|
|
2000
|
+
top_replies: scan.top_replies,
|
|
1964
2001
|
grounding_instructions: scan.grounding_instructions,
|
|
1965
2002
|
website_research_instructions: WEBSITE_RESEARCH_INSTRUCTIONS,
|
|
1966
2003
|
onboarding: onboardingSnapshot(),
|
|
1967
2004
|
next_step: "FOUR steps, in order. FIRST (VOICE, from this scan): read the bio, posts, and comments " +
|
|
1968
2005
|
"as GROUND TRUTH and, per grounding_instructions, extract their profession/identity, " +
|
|
1969
|
-
"voice & vibe (tone, phrasing, casing, tics),
|
|
2006
|
+
"voice & vibe (tone, phrasing, casing, tics), verbatim golden-rule example replies (the " +
|
|
2007
|
+
"scan pre-ranks these by real engagement in top_replies/top_posts, with stats, parent " +
|
|
2008
|
+
"tweets, and thread continuations; persist them into the project's voice.examples so " +
|
|
2009
|
+
"every drafter mirrors them), " +
|
|
1970
2010
|
"a phrase bank + things they avoid, and their icp. The scan is BACKWARD-LOOKING (only what " +
|
|
1971
2011
|
"they already posted) so it is the source for VOICE, not the primary source for topics. " +
|
|
1972
2012
|
"SECOND (the DICTATION interview — this is where TOPICS + grounding corpus come from, do NOT " +
|
|
@@ -2079,6 +2119,13 @@ tool("project_config", {
|
|
|
2079
2119
|
});
|
|
2080
2120
|
}
|
|
2081
2121
|
const result = applySetup(args);
|
|
2122
|
+
// Persist the profile scan's engagement-ranked exemplars into this
|
|
2123
|
+
// project's voice.examples (+ the persona corpus section when the target
|
|
2124
|
+
// is the persona). Reads the last_profile_scan.json sidecar the scanner
|
|
2125
|
+
// wrote; verbatim quotes of the user's own public replies, so no
|
|
2126
|
+
// synthesis happens here. Best-effort: no scan yet or hand-written
|
|
2127
|
+
// examples present (exit 3) are both fine, and never block the save.
|
|
2128
|
+
const exemplarNote = await applyScannedVoiceExamples(result.project);
|
|
2082
2129
|
if (result.persona) {
|
|
2083
2130
|
// no-op on the onboarding ledger; readiness is reported below as usual.
|
|
2084
2131
|
}
|
|
@@ -2176,6 +2223,7 @@ tool("project_config", {
|
|
|
2176
2223
|
kicker_detail: kickerInstall ? kickerInstall.detail : null,
|
|
2177
2224
|
fields_set: result.fields_set,
|
|
2178
2225
|
fields_removed: result.fields_removed,
|
|
2226
|
+
voice_examples: exemplarNote,
|
|
2179
2227
|
config_path: configPath(),
|
|
2180
2228
|
onboarding: onboardingSnapshot(),
|
|
2181
2229
|
note: (result.persona
|
|
@@ -2192,7 +2240,8 @@ tool("project_config", {
|
|
|
2192
2240
|
: `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
|
|
2193
2241
|
`First derive those fields from existing context, profile_scan, and website research, then ` +
|
|
2194
2242
|
`call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
|
|
2195
|
-
advancedNote
|
|
2243
|
+
advancedNote +
|
|
2244
|
+
(exemplarNote ? ` ${exemplarNote}` : ""),
|
|
2196
2245
|
});
|
|
2197
2246
|
}
|
|
2198
2247
|
catch (e) {
|
|
@@ -2418,19 +2467,33 @@ tool("get_stats", {
|
|
|
2418
2467
|
title: "Get X/Twitter stats",
|
|
2419
2468
|
description: "Read-only post + engagement stats for the X/Twitter rail over the last N days. " +
|
|
2420
2469
|
"Wraps project_stats_json.py. Use to show the user how their posts are performing. " +
|
|
2470
|
+
"With no `project` it reports EVERY configured lane, including the personal-brand " +
|
|
2471
|
+
"persona (which usually carries most of the volume) — prefer that default. " +
|
|
2421
2472
|
"After returning the numbers, call the `dashboard` tool so the user sees them rendered.",
|
|
2422
2473
|
inputSchema: {
|
|
2423
2474
|
days: z.number().int().min(1).max(90).default(7),
|
|
2424
2475
|
project: z
|
|
2425
2476
|
.string()
|
|
2426
2477
|
.optional()
|
|
2427
|
-
.describe("
|
|
2478
|
+
.describe("Scope to one configured project (the persona lane's name works too). " +
|
|
2479
|
+
"Omit to report all lanes — products AND the personal-brand persona."),
|
|
2428
2480
|
},
|
|
2429
2481
|
}, async ({ days, project }) => {
|
|
2430
|
-
|
|
2431
|
-
|
|
2482
|
+
// Explicit project: validate it (projectStatus is persona-aware, so the
|
|
2483
|
+
// persona lane resolves). No project: report EVERY lane rather than
|
|
2484
|
+
// resolving to a single product — the old single-project resolution made
|
|
2485
|
+
// the persona lane (often 90% of activity) invisible in stats.
|
|
2486
|
+
let proj;
|
|
2487
|
+
if (project) {
|
|
2488
|
+
const r = resolveProject(project);
|
|
2489
|
+
if (!r.ok)
|
|
2490
|
+
return textContent(r.message);
|
|
2491
|
+
proj = r.project;
|
|
2492
|
+
}
|
|
2493
|
+
else if (!hasReadyProject() && !personaReady()) {
|
|
2494
|
+
const r = resolveProject();
|
|
2432
2495
|
return textContent(r.message);
|
|
2433
|
-
|
|
2496
|
+
}
|
|
2434
2497
|
const args = ["--posts-only", "--platform", "twitter", "--days", String(days)];
|
|
2435
2498
|
if (proj)
|
|
2436
2499
|
args.push("--project", proj);
|
|
@@ -5247,6 +5310,28 @@ async function main() {
|
|
|
5247
5310
|
void sendStateSnapshot("startup");
|
|
5248
5311
|
const ss = setInterval(() => void sendStateSnapshot("interval"), 15 * 60_000);
|
|
5249
5312
|
ss.unref();
|
|
5313
|
+
// One-shot voice-exemplar catch-up for installs onboarded BEFORE the
|
|
5314
|
+
// exemplar feature: if the persona has no voice.examples_scanned_at, rescan
|
|
5315
|
+
// the connected X profile and store the top-performing replies as
|
|
5316
|
+
// voice.examples + the persona_corpus.txt exemplar section. Additive only
|
|
5317
|
+
// (regenerates just its own marked corpus section; respects hand-written
|
|
5318
|
+
// examples) and self-limiting (marker file rate-limits scan attempts;
|
|
5319
|
+
// BAIL-ON-BUSY on the twitter-browser lock, so it never contends with a
|
|
5320
|
+
// running cycle — it just retries on a later boot). Delayed so boot-time
|
|
5321
|
+
// work (runtime provision, kicker install) settles first.
|
|
5322
|
+
const backfill = setTimeout(() => {
|
|
5323
|
+
if (isPaused())
|
|
5324
|
+
return;
|
|
5325
|
+
void runPython("scripts/voice_exemplars.py", ["backfill"], { timeoutMs: 420_000 })
|
|
5326
|
+
.then((r) => {
|
|
5327
|
+
const last = r.stdout.trim().split("\n").slice(-1)[0] || "";
|
|
5328
|
+
console.error(`[social-autoposter-mcp] voice-exemplars backfill: ${last}`);
|
|
5329
|
+
})
|
|
5330
|
+
.catch((e) => {
|
|
5331
|
+
console.error("[social-autoposter-mcp] voice-exemplars backfill failed:", e?.message || e);
|
|
5332
|
+
});
|
|
5333
|
+
}, 3 * 60_000);
|
|
5334
|
+
backfill.unref();
|
|
5250
5335
|
}
|
|
5251
5336
|
main().catch(async (err) => {
|
|
5252
5337
|
console.error("[social-autoposter-mcp] fatal:", err);
|
package/mcp/dist/screencast.js
CHANGED
|
@@ -18,6 +18,20 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { execFile } from "node:child_process";
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
|
+
import { logLine } from "./telemetry.js";
|
|
22
|
+
// One structured relay line (context "browser-foreground", the same lane the
|
|
23
|
+
// menubar's NSWorkspace observer emits on) every time THIS module raises the
|
|
24
|
+
// managed Chrome. The observer records THAT the window came to the front;
|
|
25
|
+
// these lines record WHY (screencast attach vs explicit front action), so the
|
|
26
|
+
// two join on timestamp in Cloud Logging.
|
|
27
|
+
function logBringToFront(source, extra) {
|
|
28
|
+
try {
|
|
29
|
+
logLine("stdout", JSON.stringify({ ev: "browser_bring_to_front", source, ...extra }), "browser-foreground");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* telemetry must never break the screencast */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
21
35
|
// Untyped indirection: Node ships a global WebSocket at runtime (>=21) but
|
|
22
36
|
// @types/node doesn't always declare it as a value, and MessageEvent isn't typed
|
|
23
37
|
// without the DOM lib. Reach for it dynamically and keep the event handlers `any`.
|
|
@@ -123,6 +137,14 @@ class Screencast {
|
|
|
123
137
|
this.port = chosenPort;
|
|
124
138
|
this.targetTitle = target.title || "";
|
|
125
139
|
this.targetUrl = target.url || "";
|
|
140
|
+
// connect() just sent Page.bringToFront (Chrome won't stream frames for a
|
|
141
|
+
// background tab), which raises the harness window — record each raise.
|
|
142
|
+
// Reconnects happen silently on every frame poll after the attached tab
|
|
143
|
+
// dies, so this line is the ONLY attribution for reconnect-storm raises.
|
|
144
|
+
logBringToFront("screencast_attach", {
|
|
145
|
+
port: chosenPort,
|
|
146
|
+
url: (target.url || "").slice(0, 200),
|
|
147
|
+
});
|
|
126
148
|
return { ok: true };
|
|
127
149
|
}
|
|
128
150
|
catch (e) {
|
|
@@ -333,5 +355,10 @@ export async function bringBrowserToFront(port) {
|
|
|
333
355
|
if (process.platform === "darwin") {
|
|
334
356
|
await raiseMacWindow(chosenPort);
|
|
335
357
|
}
|
|
358
|
+
logBringToFront("front_action", {
|
|
359
|
+
port: chosenPort,
|
|
360
|
+
url: (target.url || "").slice(0, 200),
|
|
361
|
+
raised_os_window: process.platform === "darwin",
|
|
362
|
+
});
|
|
336
363
|
return { ok: true, port: chosenPort };
|
|
337
364
|
}
|
package/mcp/dist/setup.js
CHANGED
|
@@ -486,9 +486,15 @@ export function missingForProject(name, fields = REQUIRED_FIELDS) {
|
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
488
|
export function projectStatus(name) {
|
|
489
|
-
|
|
489
|
+
// The persona lane (persona:true) has no website/icp by design — validate it
|
|
490
|
+
// against PERSONA_REQUIRED_FIELDS like every other status surface, or an
|
|
491
|
+
// explicit request for it (e.g. get_stats project:'PersonalBrand') gets the
|
|
492
|
+
// misleading "still needs: website, icp" refusal.
|
|
493
|
+
const persona = findPersonaProject()?.name === name;
|
|
494
|
+
const required = persona ? PERSONA_REQUIRED_FIELDS : REQUIRED_FIELDS;
|
|
495
|
+
const missing = missingForProject(name, required);
|
|
490
496
|
if (missing === null) {
|
|
491
|
-
return { name, in_config: false, ready: false, missing_required: [...
|
|
497
|
+
return { name, in_config: false, ready: false, missing_required: [...required] };
|
|
492
498
|
}
|
|
493
499
|
return { name, in_config: true, ready: missing.length === 0, missing_required: missing };
|
|
494
500
|
}
|
package/mcp/dist/twitterAuth.js
CHANGED
|
@@ -60,8 +60,9 @@ export async function xScanProfile(opts) {
|
|
|
60
60
|
args.push("--handle", opts.handle);
|
|
61
61
|
args.push("--posts", String(opts?.posts ?? 20));
|
|
62
62
|
args.push("--comments", String(opts?.comments ?? 50));
|
|
63
|
-
// The scan scrolls two timelines
|
|
64
|
-
|
|
63
|
+
// The scan scrolls two timelines plus visits the top posts' permalinks for
|
|
64
|
+
// thread expansion; give it room but keep it bounded.
|
|
65
|
+
const res = await runPython("scripts/scan_x_profile.py", args, { timeoutMs: 240_000 });
|
|
65
66
|
try {
|
|
66
67
|
return JSON.parse(res.stdout.trim().split("\n").slice(-1).join("\n"));
|
|
67
68
|
}
|
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.4-rc.1",
|
|
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": {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Ground-truth "the harness browser just went foreground" telemetry.
|
|
2
|
+
|
|
3
|
+
Customers reported the managed Chrome popping over their work (1.7.x made the
|
|
4
|
+
launchd kicker keep a harness Chrome alive all day, the screencast reconnect
|
|
5
|
+
sends Page.bringToFront, and single-display Macs clamp the off-screen
|
|
6
|
+
--window-position back on-screen). None of those moments were recorded
|
|
7
|
+
anywhere: the causing sites had no logs and nothing observed the OS z-order.
|
|
8
|
+
|
|
9
|
+
This module is the cause-agnostic observer. It subscribes to NSWorkspace
|
|
10
|
+
activate/launch notifications and, whenever the app coming to the front is a
|
|
11
|
+
Chrome/Chromium whose command line carries a MANAGED profile
|
|
12
|
+
(~/.claude/browser-profiles/browser-harness*), emits one structured JSON line
|
|
13
|
+
via s4l_log_relay.emit(..., context="browser-foreground"). The relay POSTs it
|
|
14
|
+
to /api/v1/installations/logs under the install's X-Installation identity, so
|
|
15
|
+
in Cloud Logging (project s4l-app-prod) the events are:
|
|
16
|
+
|
|
17
|
+
jsonPayload.context="browser-foreground"
|
|
18
|
+
AND jsonPayload.install_id="<uuid>"
|
|
19
|
+
|
|
20
|
+
Payload fields: cause ("activated" fires on every raise, "launched" only on a
|
|
21
|
+
fresh Chrome process = the launch-activation steal), pid, profile + CDP port +
|
|
22
|
+
--window-position (parsed from the process command line; window-position
|
|
23
|
+
80,80 = setup_twitter_auth's on-screen login, 3042,-1032 = the pipeline
|
|
24
|
+
default), interrupted_app (the frontmost app the user lost), and
|
|
25
|
+
suppressed_since_last (burst dedupe counter, so a bringToFront storm is
|
|
26
|
+
countable without flooding the relay).
|
|
27
|
+
|
|
28
|
+
Design constraints:
|
|
29
|
+
- The notification handler must never block the main run loop: it only
|
|
30
|
+
enqueues; a daemon worker does the `ps` classification + emit.
|
|
31
|
+
- Only harness-Chrome events are emitted. The user's own Chrome, and every
|
|
32
|
+
other app switch, produce zero relay traffic (we keep the last non-harness
|
|
33
|
+
app name in memory as interrupted_app context, nothing more).
|
|
34
|
+
- Strictly best-effort: install() returning False just means no telemetry.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import queue
|
|
40
|
+
import re
|
|
41
|
+
import subprocess
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
|
|
45
|
+
import s4l_log_relay
|
|
46
|
+
|
|
47
|
+
# A managed harness Chrome is one launched on a profile under this marker
|
|
48
|
+
# (browser-harness = twitter 9555, browser-harness-linkedin = 9556, ...).
|
|
49
|
+
_PROFILE_MARKER = os.path.join(".claude", "browser-profiles", "browser-harness")
|
|
50
|
+
|
|
51
|
+
# Within this window, repeats of the same (cause, pid) are counted, not emitted.
|
|
52
|
+
# A screencast-reconnect storm raises Chrome every few seconds; one line per
|
|
53
|
+
# 30s with suppressed_since_last preserves the frequency without the flood.
|
|
54
|
+
_DEDUPE_SECONDS = 30.0
|
|
55
|
+
|
|
56
|
+
_events = queue.Queue()
|
|
57
|
+
_started = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmdline(pid):
|
|
61
|
+
try:
|
|
62
|
+
out = subprocess.run(
|
|
63
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
64
|
+
capture_output=True, text=True, timeout=3,
|
|
65
|
+
)
|
|
66
|
+
return (out.stdout or "").strip()
|
|
67
|
+
except Exception:
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _Worker(threading.Thread):
|
|
72
|
+
def __init__(self):
|
|
73
|
+
super().__init__(daemon=True, name="s4l-browser-foreground")
|
|
74
|
+
self._pid_cache = {} # pid -> (is_harness, details dict)
|
|
75
|
+
self._prev_app = None # last non-harness frontmost app name
|
|
76
|
+
self._last_key = None # (cause, pid) of last emitted event
|
|
77
|
+
self._last_emit_at = 0.0
|
|
78
|
+
self._suppressed = 0
|
|
79
|
+
|
|
80
|
+
def _classify(self, pid):
|
|
81
|
+
cached = self._pid_cache.get(pid)
|
|
82
|
+
if cached is not None:
|
|
83
|
+
return cached
|
|
84
|
+
cmd = _cmdline(pid)
|
|
85
|
+
is_harness = _PROFILE_MARKER in cmd and "--remote-debugging-port=" in cmd
|
|
86
|
+
details = {}
|
|
87
|
+
if is_harness:
|
|
88
|
+
m = re.search(r"--user-data-dir=(\S+)", cmd)
|
|
89
|
+
details["profile"] = os.path.basename(m.group(1).rstrip("/")) if m else ""
|
|
90
|
+
m = re.search(r"--remote-debugging-port=(\d+)", cmd)
|
|
91
|
+
details["port"] = int(m.group(1)) if m else None
|
|
92
|
+
m = re.search(r"--window-position=(\S+)", cmd)
|
|
93
|
+
details["window_position"] = m.group(1) if m else None
|
|
94
|
+
result = (is_harness, details)
|
|
95
|
+
# pids recycle rarely; a tiny bounded cache is enough and self-clears.
|
|
96
|
+
if len(self._pid_cache) > 64:
|
|
97
|
+
self._pid_cache.clear()
|
|
98
|
+
self._pid_cache[pid] = result
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def _handle(self, cause, pid, name, low):
|
|
102
|
+
if "chrome" not in low and "chromium" not in low:
|
|
103
|
+
if cause == "activated" and name:
|
|
104
|
+
self._prev_app = name
|
|
105
|
+
return
|
|
106
|
+
is_harness, details = self._classify(pid)
|
|
107
|
+
if not is_harness:
|
|
108
|
+
# The user's own Chrome counts as their workspace too.
|
|
109
|
+
if cause == "activated" and name:
|
|
110
|
+
self._prev_app = name
|
|
111
|
+
return
|
|
112
|
+
now = time.time()
|
|
113
|
+
key = (cause, pid)
|
|
114
|
+
if key == self._last_key and now - self._last_emit_at < _DEDUPE_SECONDS:
|
|
115
|
+
self._suppressed += 1
|
|
116
|
+
return
|
|
117
|
+
payload = {
|
|
118
|
+
"ev": "harness_browser_foregrounded",
|
|
119
|
+
"cause": cause, # "activated" | "launched"
|
|
120
|
+
"app": name,
|
|
121
|
+
"pid": pid,
|
|
122
|
+
"interrupted_app": self._prev_app,
|
|
123
|
+
"suppressed_since_last": self._suppressed,
|
|
124
|
+
}
|
|
125
|
+
payload.update(details)
|
|
126
|
+
self._last_key = key
|
|
127
|
+
self._last_emit_at = now
|
|
128
|
+
self._suppressed = 0
|
|
129
|
+
s4l_log_relay.emit(
|
|
130
|
+
"[browser-foreground] " + json.dumps(payload, ensure_ascii=False),
|
|
131
|
+
context="browser-foreground",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def run(self):
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
cause, pid, name, low = _events.get()
|
|
138
|
+
self._handle(cause, pid, name, low)
|
|
139
|
+
except Exception:
|
|
140
|
+
# Never die: a bad event must not end foreground telemetry.
|
|
141
|
+
time.sleep(0.5)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def install():
|
|
145
|
+
"""Subscribe to NSWorkspace activate/launch notifications and start the
|
|
146
|
+
classifier worker. Call once at menubar boot (the AppKit run loop rumps
|
|
147
|
+
starts delivers the notifications). Returns True on success; never raises."""
|
|
148
|
+
global _started
|
|
149
|
+
if _started:
|
|
150
|
+
return True
|
|
151
|
+
try:
|
|
152
|
+
from AppKit import NSWorkspace
|
|
153
|
+
|
|
154
|
+
nc = NSWorkspace.sharedWorkspace().notificationCenter()
|
|
155
|
+
|
|
156
|
+
def _make(cause):
|
|
157
|
+
def _handler(note):
|
|
158
|
+
try:
|
|
159
|
+
app = note.userInfo().objectForKey_("NSWorkspaceApplicationKey")
|
|
160
|
+
if app is None:
|
|
161
|
+
return
|
|
162
|
+
name = str(app.localizedName() or "")
|
|
163
|
+
bid = str(app.bundleIdentifier() or "")
|
|
164
|
+
pid = int(app.processIdentifier())
|
|
165
|
+
_events.put((cause, pid, name, (name + " " + bid).lower()))
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return _handler
|
|
169
|
+
|
|
170
|
+
# Keep the block handlers referenced for the process lifetime: PyObjC
|
|
171
|
+
# does retain the blocks it wraps, but the observer tokens returned
|
|
172
|
+
# here are our only handle if we ever need to removeObserver_.
|
|
173
|
+
tokens = [
|
|
174
|
+
nc.addObserverForName_object_queue_usingBlock_(
|
|
175
|
+
"NSWorkspaceDidActivateApplicationNotification", None, None,
|
|
176
|
+
_make("activated"),
|
|
177
|
+
),
|
|
178
|
+
nc.addObserverForName_object_queue_usingBlock_(
|
|
179
|
+
"NSWorkspaceDidLaunchApplicationNotification", None, None,
|
|
180
|
+
_make("launched"),
|
|
181
|
+
),
|
|
182
|
+
]
|
|
183
|
+
install._tokens = tokens # noqa: SLF001 (lifetime anchor)
|
|
184
|
+
_Worker().start()
|
|
185
|
+
_started = True
|
|
186
|
+
return True
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|