@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 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
- timeoutMs: 900_000,
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), 2-4 verbatim golden-rule example replies, " +
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("Which configured project to report on. Optional when only one project is set up; required when several are."),
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
- const r = resolveProject(project);
2431
- if (!r.ok)
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
- const proj = r.project;
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);
@@ -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
- const missing = missingForProject(name);
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: [...REQUIRED_FIELDS] };
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
  }
@@ -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; give it room but keep it bounded.
64
- const res = await runPython("scripts/scan_x_profile.py", args, { timeoutMs: 180_000 });
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
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.2",
3
- "installedAt": "2026-07-10T15:25:23.017Z"
2
+ "version": "1.7.4-rc.1",
3
+ "installedAt": "2026-07-10T22:41:18.186Z"
4
4
  }
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.2",
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