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 +25 -6
- package/mcp/dist/screencast.js +27 -0
- package/mcp/dist/setup.js +8 -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/merge_review_queue.py +24 -4
- package/scripts/pick_project.py +12 -3
- package/scripts/release-mcpb.sh +15 -1
- package/scripts/s4l_mode.py +9 -8
- package/scripts/twitter_post_plan.py +67 -0
- package/skill/run-draft-and-publish.sh +4 -3
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
|
|
@@ -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("
|
|
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
|
-
|
|
2431
|
-
|
|
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
|
-
|
|
2452
|
+
}
|
|
2434
2453
|
const args = ["--posts-only", "--platform", "twitter", "--days", String(days)];
|
|
2435
2454
|
if (proj)
|
|
2436
2455
|
args.push("--project", proj);
|
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/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.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
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -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
|
|
17
|
-
panel
|
|
18
|
-
|
|
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 (
|
|
138
|
-
#
|
|
139
|
-
#
|
|
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
|
|
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
|
-
"""
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
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
|
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
@@ -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
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
|
|
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(),
|
package/scripts/pick_project.py
CHANGED
|
@@ -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,
|
|
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
|
|
126
|
-
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
|
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -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
|
-
&& (
|
|
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
|
package/scripts/s4l_mode.py
CHANGED
|
@@ -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
|
|
62
|
-
While ON (the normal state), every
|
|
63
|
-
become review cards. When an
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|