social-autoposter 1.7.2 → 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";
@@ -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
@@ -2418,19 +2423,33 @@ tool("get_stats", {
2418
2423
  title: "Get X/Twitter stats",
2419
2424
  description: "Read-only post + engagement stats for the X/Twitter rail over the last N days. " +
2420
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. " +
2421
2428
  "After returning the numbers, call the `dashboard` tool so the user sees them rendered.",
2422
2429
  inputSchema: {
2423
2430
  days: z.number().int().min(1).max(90).default(7),
2424
2431
  project: z
2425
2432
  .string()
2426
2433
  .optional()
2427
- .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."),
2428
2436
  },
2429
2437
  }, async ({ days, project }) => {
2430
- const r = resolveProject(project);
2431
- 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();
2432
2451
  return textContent(r.message);
2433
- const proj = r.project;
2452
+ }
2434
2453
  const args = ["--posts-only", "--platform", "twitter", "--days", String(days)];
2435
2454
  if (proj)
2436
2455
  args.push("--project", proj);
@@ -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",
3
- "installedAt": "2026-07-10T15:25:23.017Z"
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",
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
@@ -13,9 +13,11 @@ The panel is NONACTIVATING and auto-presented cards never take keyboard focus
13
13
  field becomes editable on first click, and only a user-initiated open
14
14
  (present_review(focus=True), the menu bar's "Review N pending drafts")
15
15
  activates the app and seats the caret immediately. A title-bar "Snooze 1h"
16
- button sits opposite the close cross; it and the cross both just close the
17
- panel, and the menu bar interprets any close with undecided drafts as a
18
- snooze (drafts stay pending, re-present after REVIEW_SNOOZE_SECONDS).
16
+ button sits top-left where the traffic lights would be (they're hidden: the
17
+ panel can't minimize or zoom, and the cross duplicated Snooze); it just
18
+ closes the panel, and the menu bar interprets any close with undecided
19
+ drafts (including Cmd-W) as a snooze (drafts stay pending, re-present after
20
+ REVIEW_SNOOZE_SECONDS).
19
21
 
20
22
  Decision shape: {"n": int, "approved": bool, "loved": bool, "text": str,
21
23
  "edited": bool, "drop_link": bool, "reject_category": str|None,
@@ -134,14 +136,30 @@ try:
134
136
  from AppKit import NSWindowStyleMaskNonactivatingPanel
135
137
  except Exception:
136
138
  NSWindowStyleMaskNonactivatingPanel = 0
137
- # Title-bar "Snooze 1h" button (sits opposite the close cross). Missing on
138
- # very old AppKit; the card then degrades to cross-only (closing still
139
- # snoozes, the label is just absent).
139
+ # Title-bar "Snooze 1h" button (top-left, where the traffic lights would be;
140
+ # those are hidden because minimize/zoom are disabled no-ops on this panel and
141
+ # the close cross just duplicated Snooze). Missing on very old AppKit; the
142
+ # card then degrades to the stock cross (closing still snoozes, the labeled
143
+ # button is just absent) because the traffic lights are only hidden when the
144
+ # accessory actually mounted.
140
145
  try:
141
- from AppKit import NSTitlebarAccessoryViewController, NSLayoutAttributeRight
146
+ from AppKit import (
147
+ NSTitlebarAccessoryViewController,
148
+ NSLayoutAttributeLeft,
149
+ NSLayoutAttributeRight,
150
+ )
142
151
  except Exception:
143
152
  NSTitlebarAccessoryViewController = None
153
+ NSLayoutAttributeLeft = None
144
154
  NSLayoutAttributeRight = None
155
+ try:
156
+ from AppKit import (
157
+ NSWindowCloseButton,
158
+ NSWindowMiniaturizeButton,
159
+ NSWindowZoomButton,
160
+ )
161
+ except Exception:
162
+ NSWindowCloseButton = NSWindowMiniaturizeButton = NSWindowZoomButton = None
145
163
  try:
146
164
  from AppKit import NSBezelStyleInline
147
165
  except Exception:
@@ -889,41 +907,95 @@ class _ReviewController(NSObject):
889
907
 
890
908
  @objc.python_method
891
909
  def _add_snooze_accessory(self, panel):
892
- """"Snooze 1h" in the title bar, opposite the close cross: the explicit
893
- "not now" affordance. It just closes the panel; the menu bar treats any
894
- close with undecided drafts as a snooze, so the cross and this button
895
- are the same action, this one merely says so out loud."""
896
- if NSTitlebarAccessoryViewController is None or NSLayoutAttributeRight is None:
910
+ """Title-bar controls, replacing the traffic lights: "Snooze 1h" at
911
+ the top-left (minimize/zoom were disabled no-op dots on this panel,
912
+ and the close cross meant snooze anyway, so one labeled button says
913
+ what the only dismissal actually does) and "Discard all…" at the
914
+ top-right (moved here from the menu bar dropdown 2026-07-10; the
915
+ ellipsis is honest, the handler opens a confirmation alert first).
916
+ Snooze just closes the panel; the menu bar treats any close with
917
+ undecided drafts as a snooze. The traffic lights are hidden ONLY once
918
+ the snooze accessory mounts, so a failure on old AppKit leaves the
919
+ stock cross as the fallback dismissal. Cmd-W keeps working either way
920
+ (the Closable mask stays on)."""
921
+ if NSTitlebarAccessoryViewController is None or NSLayoutAttributeLeft is None:
897
922
  return
898
923
  try:
899
- btn = NSButton.alloc().initWithFrame_(NSMakeRect(0, 0, 80, 17))
900
- btn.setTitle_(_snooze_title())
901
- if NSBezelStyleInline is not None:
902
- btn.setBezelStyle_(NSBezelStyleInline)
903
- else:
904
- btn.setBezelStyle_(NSBezelStyleRounded)
905
- btn.setFont_(NSFont.systemFontOfSize_(10.0))
906
- btn.setToolTip_(
907
- "Put these drafts away for now. They stay pending and the "
908
- "card comes back later (or sooner from the S4L menu)."
924
+ self._add_titlebar_button(
925
+ panel,
926
+ title=_snooze_title(),
927
+ action="snoozeClicked:",
928
+ tooltip=(
929
+ "Put these drafts away for now. They stay pending and the "
930
+ "card comes back later (or sooner from the S4L menu)."
931
+ ),
932
+ layout=NSLayoutAttributeLeft,
909
933
  )
910
- btn.setTarget_(self)
911
- btn.setAction_("snoozeClicked:")
912
- btn.sizeToFit()
913
- bf = btn.frame()
914
- holder = NSView.alloc().initWithFrame_(
915
- NSMakeRect(0, 0, bf.size.width + 8, max(19, bf.size.height + 2))
916
- )
917
- btn.setFrameOrigin_(
918
- (4, max(0, (holder.frame().size.height - bf.size.height) / 2.0))
919
- )
920
- holder.addSubview_(btn)
921
- acc = NSTitlebarAccessoryViewController.alloc().init()
922
- acc.setView_(holder)
923
- acc.setLayoutAttribute_(NSLayoutAttributeRight)
924
- panel.addTitlebarAccessoryViewController_(acc)
934
+ self._hide_traffic_lights(panel)
925
935
  except Exception as e:
926
936
  _log(f"snooze accessory unavailable: {e}")
937
+ if _discard_all_handler is not None and NSLayoutAttributeRight is not None:
938
+ try:
939
+ self._add_titlebar_button(
940
+ panel,
941
+ title="Discard all…",
942
+ action="discardAllClicked:",
943
+ tooltip=(
944
+ "Throw away every pending draft (asks first). Nothing "
945
+ "posts, and unlike a per-card reject this sends no "
946
+ "feedback signal to the AI."
947
+ ),
948
+ layout=NSLayoutAttributeRight,
949
+ )
950
+ except Exception as e:
951
+ _log(f"discard-all accessory unavailable: {e}")
952
+
953
+ @objc.python_method
954
+ def _add_titlebar_button(self, panel, title, action, tooltip, layout):
955
+ btn = NSButton.alloc().initWithFrame_(NSMakeRect(0, 0, 80, 17))
956
+ btn.setTitle_(title)
957
+ if NSBezelStyleInline is not None:
958
+ btn.setBezelStyle_(NSBezelStyleInline)
959
+ else:
960
+ btn.setBezelStyle_(NSBezelStyleRounded)
961
+ btn.setFont_(NSFont.systemFontOfSize_(10.0))
962
+ btn.setToolTip_(tooltip)
963
+ btn.setTarget_(self)
964
+ btn.setAction_(action)
965
+ btn.sizeToFit()
966
+ bf = btn.frame()
967
+ holder = NSView.alloc().initWithFrame_(
968
+ NSMakeRect(0, 0, bf.size.width + 8, max(19, bf.size.height + 2))
969
+ )
970
+ btn.setFrameOrigin_(
971
+ (4, max(0, (holder.frame().size.height - bf.size.height) / 2.0))
972
+ )
973
+ holder.addSubview_(btn)
974
+ acc = NSTitlebarAccessoryViewController.alloc().init()
975
+ acc.setView_(holder)
976
+ acc.setLayoutAttribute_(layout)
977
+ panel.addTitlebarAccessoryViewController_(acc)
978
+
979
+ @objc.python_method
980
+ def _hide_traffic_lights(self, panel):
981
+ """Hide close/minimize/zoom once the Snooze accessory is up: minimize
982
+ and zoom are disabled dots (no Miniaturizable/Resizable in the mask)
983
+ and the cross duplicated Snooze. Hiding, not removing: performClose_
984
+ (Cmd-W and snoozeClicked_) still routes through the hidden close
985
+ button's machinery, and windowShouldClose_ still fires."""
986
+ if NSWindowCloseButton is None:
987
+ return
988
+ for which in (
989
+ NSWindowCloseButton,
990
+ NSWindowMiniaturizeButton,
991
+ NSWindowZoomButton,
992
+ ):
993
+ try:
994
+ b = panel.standardWindowButton_(which)
995
+ if b is not None:
996
+ b.setHidden_(True)
997
+ except Exception:
998
+ pass
927
999
 
928
1000
  def snoozeClicked_(self, sender):
929
1001
  self._track("snooze")
@@ -933,6 +1005,20 @@ class _ReviewController(NSObject):
933
1005
  except Exception:
934
1006
  self._finish()
935
1007
 
1008
+ def discardAllClicked_(self, sender):
1009
+ """Hand off to the menu bar's bulk-discard handler (registered via
1010
+ set_discard_all_handler); it confirms with the user, flips the store,
1011
+ and dismisses this panel via dismiss_active(), so nothing more happens
1012
+ here. On cancel the card simply stays up."""
1013
+ self._track("discard_all")
1014
+ cb = _discard_all_handler
1015
+ if cb is None:
1016
+ return
1017
+ try:
1018
+ cb()
1019
+ except Exception as e:
1020
+ _log(f"discard-all handler failed: {e}")
1021
+
936
1022
  @objc.python_method
937
1023
  def _eye_button(self, frame, kind):
938
1024
  """Borderless SF-Symbol eye whose hover/click opens a popover. A plain
@@ -2122,6 +2208,21 @@ def set_feedback_handler(cb):
2122
2208
  _feedback_handler = cb
2123
2209
 
2124
2210
 
2211
+ # Bulk-discard hook for the title bar's "Discard all…" button, same
2212
+ # registration pattern as _feedback_handler above. The menu bar registers
2213
+ # _discard_all_pending here (it owns the confirmation alert, the store flip,
2214
+ # and dismissing the panel). Cards built while this is None simply don't get
2215
+ # the button.
2216
+ _discard_all_handler = None
2217
+
2218
+
2219
+ def set_discard_all_handler(cb):
2220
+ """Register the title-bar "Discard all…" action. The menu bar calls this
2221
+ before presenting cards with its bulk-discard handler."""
2222
+ global _discard_all_handler
2223
+ _discard_all_handler = cb
2224
+
2225
+
2125
2226
  def _feedback_frame():
2126
2227
  """Just below the open review card when there is one (so it never covers
2127
2228
  the card being reviewed), else the top-right corner of the pointer's
@@ -165,6 +165,38 @@ def _loop():
165
165
  pass
166
166
 
167
167
 
168
+ def emit(line, context="menubar"):
169
+ """Buffer ONE structured line for the relay under a caller-chosen context
170
+ (e.g. "browser-foreground"), and mirror it to the real stderr so the local
171
+ menubar.err.log keeps a copy. Bypasses the _Tee so the line is not ALSO
172
+ buffered under the default "menubar" context. Best-effort; never raises."""
173
+ try:
174
+ t = str(line).strip()
175
+ if not t or _BASE64_BLOB_RE.match(t):
176
+ return
177
+ real = sys.stderr._real if isinstance(sys.stderr, _Tee) else sys.stderr
178
+ try:
179
+ real.write(t + "\n")
180
+ real.flush()
181
+ except Exception:
182
+ pass
183
+ if not ENABLED:
184
+ return
185
+ with _lock:
186
+ _buf.append(
187
+ {
188
+ "ts": _now_iso(),
189
+ "stream": "stdout",
190
+ "line": t[:MAX_LINE_LEN],
191
+ "context": str(context or "menubar")[:200],
192
+ }
193
+ )
194
+ if len(_buf) > MAX_BUFFER:
195
+ del _buf[: len(_buf) - MAX_BUFFER]
196
+ except Exception:
197
+ pass
198
+
199
+
168
200
  def install():
169
201
  """Wrap sys.stderr with the relay tee and start the background flusher.
170
202
  Call once at menubar boot, after the S4L_REPO_DIR sys.path insertion."""
@@ -117,6 +117,19 @@ try:
117
117
  except Exception:
118
118
  pass
119
119
 
120
+ # Ground-truth browser-foreground telemetry: whenever a MANAGED harness Chrome
121
+ # becomes the frontmost app (fresh-launch activation, screencast bringToFront,
122
+ # macOS clamping the off-screen window back on-screen, anything), ship one
123
+ # structured line with context "browser-foreground" through the relay above.
124
+ # Cause-agnostic OS-level observation; the causing sites (screencast.ts) log
125
+ # their own attribution lines under the same context. Best-effort.
126
+ try:
127
+ import s4l_browser_foreground # noqa: E402
128
+
129
+ s4l_browser_foreground.install()
130
+ except Exception:
131
+ pass
132
+
120
133
 
121
134
  def _capture(err, **tags):
122
135
  """Report a handled menu-bar error to Sentry (component=menubar) without ever
@@ -2800,8 +2813,11 @@ class S4LMenuBar(rumps.App):
2800
2813
 
2801
2814
  # present_feedback (the menu bar's feedback item) falls back to
2802
2815
  # the module-level default handler; register ours before any
2803
- # card shows.
2816
+ # card shows. Same pattern for the title bar's "Discard all…"
2817
+ # button (moved out of the dropdown 2026-07-10), which reuses the
2818
+ # bulk-discard handler wholesale, confirmation alert included.
2804
2819
  s4l_card.set_feedback_handler(self._on_feedback_text)
2820
+ s4l_card.set_discard_all_handler(self._discard_all_pending)
2805
2821
  s4l_card.present_review(
2806
2822
  drafts,
2807
2823
  on_decision=lambda d: self._on_card_decision(batch, d),
@@ -3531,12 +3547,9 @@ class S4LMenuBar(rumps.App):
3531
3547
  )
3532
3548
  )
3533
3549
  )
3534
- items.append(
3535
- rumps.MenuItem(
3536
- f"Discard {pending_count} pending draft{plural}…",
3537
- callback=self._discard_all_pending,
3538
- )
3539
- )
3550
+ # No bulk-discard item here anymore: "Discard all…" lives in the
3551
+ # review card's title bar (2026-07-10). Reaching it with no card open
3552
+ # is "Review N pending drafts" -> Discard all, two clicks.
3540
3553
  return items
3541
3554
 
3542
3555
 
@@ -736,6 +736,37 @@ def _match_candidate(data, n, candidate_id):
736
736
  return None
737
737
 
738
738
 
739
+ def candidate_state(c):
740
+ """Canonical lifecycle state of ONE review-queue candidate. Use this instead
741
+ of ad hoc flag checks — "not posted" is NOT "awaiting review".
742
+
743
+ The review queue (review-queue.json) is an APPEND-FOREVER LEDGER: handled
744
+ candidates are never removed, they are flag-stamped in place, so most rows
745
+ in an old queue are retired. States, in precedence order:
746
+
747
+ posted — approved and successfully posted (our_url set).
748
+ terminal — retired without a post; terminal_reason says why
749
+ (rejected, human_rejected, human_discarded_all,
750
+ duplicate_thread_pre_post, ...; None on older rows).
751
+ post_failed — approved but the post attempt errored (post_error says
752
+ why). Settled from the cards' point of view; the resume
753
+ path retries only after a fresh approval clears it.
754
+ approved — human approved, post not yet attempted/confirmed.
755
+ awaiting_review — none of the above. The ONLY state cards present, and
756
+ the only honest "pending" count (review-request.json's
757
+ .count mirrors it at merge time).
758
+ """
759
+ if c.get("posted") is True:
760
+ return "posted"
761
+ if c.get("terminal") is True:
762
+ return "terminal"
763
+ if c.get("post_failed"):
764
+ return "post_failed"
765
+ if c.get("approved") is True:
766
+ return "approved"
767
+ return "awaiting_review"
768
+
769
+
739
770
  def store_stamp_decision(batch, decision):
740
771
  """Write a card decision INTO the store the instant the user clicks. This is
741
772
  the durable record (the old approved-queue.json ledger is no longer
@@ -784,7 +815,7 @@ def discard_all_pending(drafts):
784
815
  done = 0
785
816
  for d in drafts:
786
817
  c = _match_candidate(data, d.get("n"), d.get("candidate_id"))
787
- if c is None or c.get("posted") is True or c.get("terminal") is True:
818
+ if c is None or candidate_state(c) in ("posted", "terminal"):
788
819
  continue
789
820
  c["terminal"] = True
790
821
  c["terminal_reason"] = "human_discarded_all"
@@ -865,7 +896,7 @@ def store_pending_posts(batch="review-queue"):
865
896
  plan = read_plan(store_path())
866
897
  out = []
867
898
  for i, c in enumerate((plan or {}).get("candidates") or []):
868
- if not c.get("approved") or c.get("posted") or c.get("terminal") or c.get("post_failed"):
899
+ if candidate_state(c) != "approved":
869
900
  continue
870
901
  d = c.get("decision") or {}
871
902
  out.append(
@@ -932,7 +963,7 @@ def review_queue_posted_count():
932
963
  cands = (plan or {}).get("candidates")
933
964
  if not cands:
934
965
  return None
935
- return sum(1 for c in cands if c.get("posted") is True)
966
+ return sum(1 for c in cands if candidate_state(c) == "posted")
936
967
 
937
968
 
938
969
  def _plan_generation(batch):
@@ -1008,7 +1039,9 @@ def review_drafts(plan, batch="review-queue"):
1008
1039
  settled_ns = {it.get("n") for it in items if it.get("candidate_id") is None}
1009
1040
  out = []
1010
1041
  for i, c in enumerate(((plan or {}).get("candidates") or [])):
1011
- if c.get("posted") is True or c.get("terminal") is True or c.get("approved") is True:
1042
+ # Only awaiting_review rows become cards. This also skips post_failed
1043
+ # rows (decided-but-failed is settled per the docstring above).
1044
+ if candidate_state(c) != "awaiting_review":
1012
1045
  continue
1013
1046
  cid = c.get("candidate_id")
1014
1047
  if cid is not None and cid in settled_ids:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -19,6 +19,15 @@ Usage:
19
19
 
20
20
  State dir (for review-request.json) honors $S4L_STATE_DIR; the review-queue plan
21
21
  lives in $S4L_TMP_DIR or /tmp (matching the MCP's planPath()).
22
+
23
+ LEDGER SEMANTICS (read this before counting anything in review-queue.json):
24
+ the queue is APPEND-FOREVER — handled candidates are never removed, they are
25
+ flag-stamped in place (posted / terminal+terminal_reason / post_failed /
26
+ approved). "Not posted" is therefore NOT "awaiting review"; in an old queue
27
+ most rows are retired. The canonical classifier is
28
+ mcp/menubar/s4l_state.py::candidate_state(); the honest pending-cards count is
29
+ its awaiting_review bucket, mirrored into review-request.json's .count here at
30
+ merge time.
22
31
  """
23
32
 
24
33
  from __future__ import annotations
@@ -318,10 +327,21 @@ def main() -> int:
318
327
  _atomic_write(dst, plan_obj)
319
328
  ensure_store_symlink()
320
329
 
321
- # Refresh the review-request marker the menu bar polls (count = pending,
322
- # not posted, not terminal -- a just-pruned expired card must not still
323
- # inflate the badge).
324
- pending_count = len([c for c in merged if not c.get("posted") and not c.get("terminal")])
330
+ # Refresh the review-request marker the menu bar polls. count = cards
331
+ # actually awaiting review (mirrors s4l_state.candidate_state()'s
332
+ # awaiting_review bucket): approved-unposted and post_failed rows are
333
+ # settled decisions, counting them inflated the badge and misled every
334
+ # human/agent reading the marker.
335
+ pending_count = len(
336
+ [
337
+ c
338
+ for c in merged
339
+ if not c.get("posted")
340
+ and not c.get("terminal")
341
+ and not c.get("post_failed")
342
+ and not c.get("approved")
343
+ ]
344
+ )
325
345
  project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
326
346
  _atomic_write(
327
347
  review_request_path(),
@@ -74,10 +74,17 @@ def recent_posts_by_project(platform=None, days=RECENT_WINDOW_DAYS):
74
74
 
75
75
 
76
76
  def _eligible_pool(config, platform=None, exclude=None):
77
- """Projects eligible for selection: enabled, weight>0, platform-compatible."""
77
+ """Projects eligible for selection: enabled, weight>0, non-persona,
78
+ platform-compatible.
79
+
80
+ Persona entries (`persona: true`) are structurally excluded: they run ONLY
81
+ via the personal_brand lane's force-inject (s4l_mode.persona_name), never
82
+ via this promotion lottery, regardless of their `enabled` flag.
83
+ """
78
84
  pool = [
79
85
  p for p in config.get("projects", [])
80
86
  if p.get("enabled", True) and p.get("weight", 0) > 0
87
+ and p.get("persona") is not True
81
88
  ]
82
89
  if exclude:
83
90
  excluded = {n.lower() for n in exclude}
@@ -122,8 +129,10 @@ def pick_project(config, platform=None, exclude=None):
122
129
  return picks[0]
123
130
  if exclude:
124
131
  return None
125
- # No eligible project at all: legacy fallback to any project in config.
126
- projects = config.get("projects", [])
132
+ # No eligible project at all: legacy fallback to any non-persona project.
133
+ projects = [
134
+ p for p in config.get("projects", []) if p.get("persona") is not True
135
+ ]
127
136
  return random.choice(projects) if projects else None
128
137
 
129
138
 
@@ -460,7 +460,21 @@ if [[ "$DO_NPM" == "1" ]]; then
460
460
  fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
461
461
  console.log(' temp copy renamed to $S4L_ALIAS_PKG@'+j.version+' (repo package.json untouched)');
462
462
  " \
463
- && ( cd "$S4L_PUB_DIR/package" && npm publish --access=public ${NPM_TAG_ARGS[@]+"${NPM_TAG_ARGS[@]}"} ); then
463
+ && (
464
+ # The registry's large-upload path flakes (TLS bad record mac / EPIPE)
465
+ # often enough that one attempt let the alias lane silently drift for
466
+ # three rc's (rc.16/21/22, caught 2026-07-10). Retry, and between
467
+ # attempts check whether the upload actually landed despite the
468
+ # client-side error (EPIPE can be a false negative).
469
+ cd "$S4L_PUB_DIR/package" || exit 1
470
+ for _try in 1 2 3; do
471
+ npm publish --access=public ${NPM_TAG_ARGS[@]+"${NPM_TAG_ARGS[@]}"} && exit 0
472
+ sleep 3
473
+ [[ "$(curl -s -o /dev/null -w '%{http_code}' "$S4L_ALIAS_URL")" == "200" ]] && exit 0
474
+ echo " dual-publish attempt $_try/3 failed" >&2
475
+ done
476
+ exit 1
477
+ ); then
464
478
  for _ in 1 2 3 4 5; do
465
479
  sleep 2
466
480
  [[ "$(curl -s -o /dev/null -w '%{http_code}' "$S4L_ALIAS_URL")" == "200" ]] && break
@@ -58,14 +58,15 @@ Usage:
58
58
  s4l_mode.py split # print the personal-brand share (0.0-1.0)
59
59
  s4l_mode.py split <value> # set it; accepts 70, 70%, or 0.7
60
60
 
61
- Draft-only (2026-07-06): a third, independent flag in mode.json, DEFAULT ON.
62
- While ON (the normal state), every cycle stops before posting and its drafts
63
- become review cards. When an OPERATOR turns it off, run-draft-and-publish.sh
64
- runs promotion-lane cycles with DRAFT_ONLY=0 so they POST autonomously (the
65
- rolling virality bar is the quality gate). Persona-lane cycles ALWAYS stay
66
- draft-only (review cards) regardless. Deliberately NOT exposed on any user
67
- surface (no MCP tool param, no menubar toggle): this CLI and mode.json are the
68
- only way to flip it.
61
+ Draft-only (2026-07-06, single global switch as of 2026-07-08): a third,
62
+ independent flag in mode.json, DEFAULT ON. While ON (the normal state), every
63
+ cycle stops before posting and its drafts become review cards. When an
64
+ OPERATOR turns it off, run-draft-and-publish.sh runs EVERY lane (promotion AND
65
+ personal_brand) with DRAFT_ONLY=0 so they POST autonomously (promotion
66
+ additionally runs behind the rolling virality bar). This applies uniformly
67
+ across lanes; persona-lane cycles do NOT stay draft-only when the flag is off.
68
+ Deliberately NOT exposed on any user surface (no MCP tool param, no menubar
69
+ toggle): this CLI and mode.json are the only way to flip it.
69
70
  `env` additionally exports S4L_CYCLE_LANE=<lane> for both lanes so the wrapper
70
71
  knows which lane this cycle is without re-deriving it.
71
72
 
@@ -43,12 +43,59 @@ import json
43
43
  import os
44
44
  import random
45
45
  import re
46
+ import signal
46
47
  import subprocess
47
48
  import sys
48
49
  import time
49
50
  from datetime import datetime, timezone
50
51
  from pathlib import Path
51
52
 
53
+
54
+ def _neuter_stream(stream) -> None:
55
+ """Point a dead pipe's fd at /dev/null so later writes (including the
56
+ interpreter-shutdown flush) can't raise BrokenPipeError again."""
57
+ try:
58
+ devnull = os.open(os.devnull, os.O_WRONLY)
59
+ os.dup2(devnull, stream.fileno())
60
+ os.close(devnull)
61
+ except Exception:
62
+ pass
63
+
64
+
65
+ _builtin_print = print
66
+
67
+
68
+ def print(*args, **kwargs): # noqa: A001 -- deliberate builtins.print shadow
69
+ # When the parent (cycle shell tree or MCP server) is killed mid-batch, our
70
+ # stdout/stderr pipes close and the next print raises BrokenPipeError. The
71
+ # old behavior unwound through excepthook (Sentry S4L-8), killing the batch
72
+ # BETWEEN posting a reply and recording it — which is how live tweets ended
73
+ # up unlogged and candidates re-feedable. Output to a dead parent is
74
+ # worthless; the bookkeeping (log_post, update_candidate, audit JSONL) is
75
+ # not. Swallow the error, neuter the fds, keep going.
76
+ try:
77
+ _builtin_print(*args, **kwargs)
78
+ except BrokenPipeError:
79
+ _neuter_stream(sys.stdout)
80
+ _neuter_stream(sys.stderr)
81
+
82
+
83
+ # Graceful SIGTERM: the MCP post_drafts timeout (and anything else that asks
84
+ # nicely before SIGKILL) sends SIGTERM. Dying instantly reopens the same
85
+ # posted-but-unrecorded window as the broken pipe, so instead flag the loop to
86
+ # stop at the next candidate boundary: current candidate finishes its
87
+ # bookkeeping, the summary and audit line still get written, exit stays 0.
88
+ _terminate_requested = False
89
+
90
+
91
+ def _on_sigterm(signum, frame):
92
+ global _terminate_requested
93
+ _terminate_requested = True
94
+ print("[post] SIGTERM received; stopping after current candidate", flush=True)
95
+
96
+
97
+ signal.signal(signal.SIGTERM, _on_sigterm)
98
+
52
99
  # This pipeline ONLY posts (never scans), so mark every twitter_browser.py reply
53
100
  # subprocess it spawns as the high-priority "post" lock role. run_subprocess
54
101
  # inherits this process env, so the child twitter_browser.py reads S4L_LOCK_ROLE
@@ -1150,6 +1197,10 @@ def main() -> int:
1150
1197
 
1151
1198
  try:
1152
1199
  for _idx, c in enumerate(candidates, start=1):
1200
+ if _terminate_requested:
1201
+ print(f"[post] stopping early on SIGTERM: {_idx - 1}/{_total} "
1202
+ f"candidates processed, posted={posted}", flush=True)
1203
+ break
1153
1204
  # Live per-post status for the S4L menu bar. LEAD with `posted` (the
1154
1205
  # REAL count of replies that actually landed), not `_idx` (the loop
1155
1206
  # position). _idx races through already-posted / deleted cards as instant
@@ -1224,6 +1275,22 @@ def main() -> int:
1224
1275
  "reason": reason or "",
1225
1276
  "our_url": c.get("our_url") or "",
1226
1277
  })
1278
+ # Per-candidate durable record: the run-level audit at the bottom of
1279
+ # main() is lost if this process is SIGKILLed mid-batch (browser-lock
1280
+ # hijack), leaving no local trace of what already posted. Separate
1281
+ # file from post-results.jsonl on purpose: that one is run-level and
1282
+ # the menu bar/dashboard parse it; don't mix schemas.
1283
+ try:
1284
+ _prog_path = os.path.join(
1285
+ REPO_DIR, "skill", "logs", "post-candidates.jsonl")
1286
+ with open(_prog_path, "a", encoding="utf-8") as _pf:
1287
+ _pf.write(json.dumps({
1288
+ "at": datetime.now(timezone.utc).isoformat(),
1289
+ "plan": plan_path.name,
1290
+ **candidate_results[-1],
1291
+ }) + "\n")
1292
+ except Exception:
1293
+ pass
1227
1294
  finally:
1228
1295
  _clear_activity()
1229
1296
  # Release the batch hold so the next scan/post can take the browser
@@ -11,9 +11,10 @@
11
11
  # review-queue cards the menu bar shows. Without this merge the cycle's
12
12
  # plan would sit in a /tmp batch file nobody reads.
13
13
  # - draft-only OFF (operator opt-out via `s4l_mode.py draft-only off`,
14
- # promotion-lane cycles only): DRAFT_ONLY=0 — the cycle posts its top-1
15
- # pick autonomously, gated by the rolling virality bar. Persona cycles
16
- # keep making cards either way.
14
+ # single global switch across EVERY lane as of 2026-07-08): DRAFT_ONLY=0
15
+ # the cycle posts its top-1 pick autonomously; promotion additionally
16
+ # gated by the rolling virality bar. Persona cycles also post
17
+ # autonomously when the flag is off; they no longer stay draft-only.
17
18
  set -uo pipefail
18
19
 
19
20
  # SAPS_->S4L_ env mirror (brand rename 2026-07-03): old plists/tasks still