@m13v/s4l 1.7.2-rc.9 → 1.7.3

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";
@@ -870,6 +870,18 @@ async function ensureTwitterBrowserForPost() {
870
870
  },
871
871
  });
872
872
  }
873
+ // A terminal stamp written by merge_review_queue.py's backend sync for a row the
874
+ // freshness gate merely EXPIRED (not posted, not skipped) records "nobody decided
875
+ // this in time" — an explicit human approval outranks it. The poster's own
876
+ // at-post-time tweet_unavailable check remains the real gate on whether the
877
+ // thread still exists. Without this override, a card approved while (or just
878
+ // before) the sync stamped it is refused as already-decided and the approval
879
+ // silently no-ops (2 of 3 approvals lost on 2026-07-10).
880
+ function expiredStampOverridable(c) {
881
+ return (c.terminal === true &&
882
+ c.posted !== true &&
883
+ c.discard_reason === "backend_status_expired");
884
+ }
873
885
  async function postApproved(batchId, plan) {
874
886
  // Drain serialization (2026-07-06 incident). Every call drains the WHOLE
875
887
  // approved backlog, so overlapping drains are pure waste and actively harmful:
@@ -922,7 +934,18 @@ async function postApproved(batchId, plan) {
922
934
  // drains the not-yet-posted approved backlog (e.g. a card a restart interrupted),
923
935
  // never re-posts a done one. This is what lets the startup backlog-drain and the
924
936
  // per-card menu-bar calls share one code path safely.
925
- const approved = (plan.candidates || []).filter((c) => c.approved === true && c.posted !== true && c.terminal !== true);
937
+ // An approved card whose only blocker is an overridable backend-expiry stamp is
938
+ // included: approval outranks the freshness gate (see expiredStampOverridable),
939
+ // and the stamp is cleared so every downstream terminal check agrees it's live.
940
+ const approved = (plan.candidates || []).filter((c) => c.approved === true &&
941
+ c.posted !== true &&
942
+ (c.terminal !== true || expiredStampOverridable(c)));
943
+ for (const c of approved) {
944
+ if (c.terminal === true) {
945
+ c.terminal = false;
946
+ delete c.discard_reason;
947
+ }
948
+ }
926
949
  if (approved.length === 0)
927
950
  return { attempted: 0, exit_code: 0, summary: "nothing approved" };
928
951
  // PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
@@ -1000,7 +1023,12 @@ async function postApproved(batchId, plan) {
1000
1023
  };
1001
1024
  }
1002
1025
  return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
1003
- 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)),
1004
1032
  env: ({
1005
1033
  S4L_SKIP_CAMPAIGN_SUFFIX: "1",
1006
1034
  // Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
@@ -2316,9 +2344,17 @@ tool("post_drafts", {
2316
2344
  });
2317
2345
  // Cross-surface de-dup: chat and the menu-bar pop-ups can both approve, so
2318
2346
  // never re-post a candidate the other surface already posted OR ruled out.
2347
+ // Exception: an overridable backend-expiry stamp yields to this explicit
2348
+ // approval (see expiredStampOverridable) — clear it and post.
2319
2349
  const alreadyDone = [];
2320
2350
  for (const n of Array.from(approve)) {
2321
- if (candidates[n - 1]?.posted === true || candidates[n - 1]?.terminal === true) {
2351
+ const c = candidates[n - 1];
2352
+ if (c && expiredStampOverridable(c)) {
2353
+ c.terminal = false;
2354
+ delete c.discard_reason;
2355
+ continue;
2356
+ }
2357
+ if (c?.posted === true || c?.terminal === true) {
2322
2358
  approve.delete(n);
2323
2359
  alreadyDone.push(n);
2324
2360
  }
@@ -2387,19 +2423,33 @@ tool("get_stats", {
2387
2423
  title: "Get X/Twitter stats",
2388
2424
  description: "Read-only post + engagement stats for the X/Twitter rail over the last N days. " +
2389
2425
  "Wraps project_stats_json.py. Use to show the user how their posts are performing. " +
2426
+ "With no `project` it reports EVERY configured lane, including the personal-brand " +
2427
+ "persona (which usually carries most of the volume) — prefer that default. " +
2390
2428
  "After returning the numbers, call the `dashboard` tool so the user sees them rendered.",
2391
2429
  inputSchema: {
2392
2430
  days: z.number().int().min(1).max(90).default(7),
2393
2431
  project: z
2394
2432
  .string()
2395
2433
  .optional()
2396
- .describe("Which configured project to report on. Optional when only one project is set up; required when several are."),
2434
+ .describe("Scope to one configured project (the persona lane's name works too). " +
2435
+ "Omit to report all lanes — products AND the personal-brand persona."),
2397
2436
  },
2398
2437
  }, async ({ days, project }) => {
2399
- const r = resolveProject(project);
2400
- if (!r.ok)
2438
+ // Explicit project: validate it (projectStatus is persona-aware, so the
2439
+ // persona lane resolves). No project: report EVERY lane rather than
2440
+ // resolving to a single product — the old single-project resolution made
2441
+ // the persona lane (often 90% of activity) invisible in stats.
2442
+ let proj;
2443
+ if (project) {
2444
+ const r = resolveProject(project);
2445
+ if (!r.ok)
2446
+ return textContent(r.message);
2447
+ proj = r.project;
2448
+ }
2449
+ else if (!hasReadyProject() && !personaReady()) {
2450
+ const r = resolveProject();
2401
2451
  return textContent(r.message);
2402
- const proj = r.project;
2452
+ }
2403
2453
  const args = ["--posts-only", "--platform", "twitter", "--days", String(days)];
2404
2454
  if (proj)
2405
2455
  args.push("--project", proj);
@@ -4963,7 +5013,9 @@ async function drainApprovedBacklog() {
4963
5013
  try {
4964
5014
  const plan = readPlan(REVIEW_QUEUE_ID);
4965
5015
  const cands = plan?.candidates || [];
4966
- const backlog = cands.filter((c) => c.approved === true && c.posted !== true && c.terminal !== true);
5016
+ const backlog = cands.filter((c) => c.approved === true &&
5017
+ c.posted !== true &&
5018
+ (c.terminal !== true || expiredStampOverridable(c)));
4967
5019
  if (!backlog.length)
4968
5020
  return;
4969
5021
  console.error(`[post] draining ${backlog.length} approved-but-unposted card(s) left from before`);
@@ -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
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.2-rc.9",
3
- "installedAt": "2026-07-09T04:41:50.762Z"
2
+ "version": "1.7.3",
3
+ "installedAt": "2026-07-10T18:26:15.755Z"
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-rc.9",
5
+ "version": "1.7.3",
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