@m13v/s4l 1.7.7 → 1.7.8-rc.2
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 +48 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +29 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/browser_lifecycle.py +23 -9
- package/scripts/claude_job.py +191 -2
- package/scripts/draft_provider.py +120 -0
- package/scripts/foreground_event_capture.sh +44 -0
- package/scripts/install.sh +119 -0
- package/scripts/scan_reddit_replies.py +111 -27
- package/scripts/schedule_state.py +16 -0
- package/scripts/setup_reddit_auth.py +62 -36
- package/scripts/setup_twitter_auth.py +121 -42
- package/scripts/virality.py +12 -1
- package/skill/engage-dm-replies.sh +22 -5
package/mcp/dist/index.js
CHANGED
|
@@ -3599,6 +3599,25 @@ function runtimeSnapshot() {
|
|
|
3599
3599
|
// host tool create_scheduled_task. Calling it also eagerly pre-approves the
|
|
3600
3600
|
// worker tools, so the tasks never stall on a permission prompt. Read-only +
|
|
3601
3601
|
// idempotent.
|
|
3602
|
+
// ---- host + draft-provider plumbing (2026-08-06) ---------------------------
|
|
3603
|
+
// Which app is hosting this MCP server. Stamped at registration time via the
|
|
3604
|
+
// S4L_HOST env on the host's server entry (the codex `mcp add` sets
|
|
3605
|
+
// S4L_HOST=codex; Claude registrations set nothing). Deliberately an env var
|
|
3606
|
+
// we control, not MCP clientInfo sniffing: deterministic across host versions.
|
|
3607
|
+
function s4lHost() {
|
|
3608
|
+
return (process.env.S4L_HOST || "").trim().toLowerCase() === "codex" ? "codex" : "claude";
|
|
3609
|
+
}
|
|
3610
|
+
// Stamp <state_dir>/draft-provider.json, the single source of truth read at the
|
|
3611
|
+
// run_claude.sh seam (scripts/draft_provider.py / claude_job.py) for which
|
|
3612
|
+
// engine answers the pipeline's pure text->JSON drafting turns. Last setup
|
|
3613
|
+
// wins: re-running queue_setup on either host takes ownership cleanly, which
|
|
3614
|
+
// is the single-driver guarantee (never two engines answering one queue).
|
|
3615
|
+
function writeDraftProvider(provider, setBy) {
|
|
3616
|
+
const p = path.join(s4lStateDir(), "draft-provider.json");
|
|
3617
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
3618
|
+
fs.writeFileSync(p, JSON.stringify({ provider, set_at: new Date().toISOString(), set_by: setBy }, null, 2), "utf-8");
|
|
3619
|
+
console.error(`[draft-provider] ${provider} (${setBy})`);
|
|
3620
|
+
}
|
|
3602
3621
|
tool("queue_setup", {
|
|
3603
3622
|
title: "Get autopilot scheduled-task specs",
|
|
3604
3623
|
description: "Returns the scheduled task that runs the hands-free draft autopilot on this machine " +
|
|
@@ -3610,6 +3629,35 @@ tool("queue_setup", {
|
|
|
3610
3629
|
"installs. Use this as the final onboarding step instead of the old per-type worker tasks.",
|
|
3611
3630
|
inputSchema: {},
|
|
3612
3631
|
}, async () => {
|
|
3632
|
+
// Codex/ChatGPT host: there is no scheduled-task worker at all. Drafting
|
|
3633
|
+
// runs INLINE in the launchd cycle through the ChatGPT-app-bundled
|
|
3634
|
+
// `codex exec` (headless, subscription-authenticated), so the entire
|
|
3635
|
+
// Claude-Desktop worker apparatus below (allow-rules, SKILL.md, folder
|
|
3636
|
+
// trust, create_scheduled_task) is skipped. Stamping the provider is the
|
|
3637
|
+
// whole setup.
|
|
3638
|
+
if (s4lHost() === "codex") {
|
|
3639
|
+
clearMenubarStop();
|
|
3640
|
+
void ensureMenubar();
|
|
3641
|
+
writeDraftProvider("codex-exec", "queue_setup(codex)");
|
|
3642
|
+
return jsonContent({
|
|
3643
|
+
tasks: [],
|
|
3644
|
+
provider: "codex-exec",
|
|
3645
|
+
expectations: [
|
|
3646
|
+
"What happens next (relay these lines to the user, in their words):",
|
|
3647
|
+
"- About every 5 minutes a background draft cycle scans X for posts that match your search topics and drafts replies in your voice, using this app's engine directly. No scheduled task and no open app window is required.",
|
|
3648
|
+
"- Drafts show up as review cards. The first one usually lands within a few minutes when there is matching supply on X; quiet topics mean fewer or no cards until something relevant is posted.",
|
|
3649
|
+
"- Nothing is posted automatically. You approve each draft yourself (from the dashboard or the menu bar); posting autopilot stays OFF until you explicitly turn it on. Today it only drafts.",
|
|
3650
|
+
"- You can edit your voice, topics, or the drafts themselves at any time, and check status on the dashboard.",
|
|
3651
|
+
],
|
|
3652
|
+
next_step: "Nothing to schedule on this host: drafting is wired to run inline via the ChatGPT app's " +
|
|
3653
|
+
"engine, driven by the background cycle this server installs. Do NOT create any scheduled " +
|
|
3654
|
+
"task or automation. Relay the `expectations` lines to the user; setup of the draft " +
|
|
3655
|
+
"autopilot is complete.",
|
|
3656
|
+
});
|
|
3657
|
+
}
|
|
3658
|
+
// Claude host: take (or retake) ownership for the Desktop queue worker.
|
|
3659
|
+
// Last setup wins — this is the single-driver guard's other half.
|
|
3660
|
+
writeDraftProvider("claude-desktop-queue", "queue_setup(claude)");
|
|
3613
3661
|
ensureQueueWorkerToolsAllowed();
|
|
3614
3662
|
// Re-arming the autopilot is an explicit "start S4L" action: lift a prior
|
|
3615
3663
|
// tray Quit so the review cards have a surface again. Best-effort and
|
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.8-rc.2",
|
|
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": {
|
|
@@ -174,9 +174,20 @@ class _Worker(threading.Thread):
|
|
|
174
174
|
try:
|
|
175
175
|
from AppKit import NSRunningApplication
|
|
176
176
|
|
|
177
|
+
hid = refocused = False
|
|
177
178
|
ra = NSRunningApplication.runningApplicationWithProcessIdentifier_(pid)
|
|
178
179
|
if ra is not None:
|
|
179
|
-
|
|
180
|
+
# hide() can race the in-flight activation and return False
|
|
181
|
+
# (observed 2026-08-06: hid=False while refocus succeeded, so
|
|
182
|
+
# the window stayed visible behind the refocused app — the
|
|
183
|
+
# "I still saw it" reports). Retry briefly and trust
|
|
184
|
+
# isHidden() as the verdict, not hide()'s return value.
|
|
185
|
+
for _ in range(4):
|
|
186
|
+
ra.hide()
|
|
187
|
+
time.sleep(0.15)
|
|
188
|
+
if ra.isHidden():
|
|
189
|
+
break
|
|
190
|
+
hid = bool(ra.isHidden())
|
|
180
191
|
# hide() alone does NOT reliably return focus here: all three
|
|
181
192
|
# harnesses share the "Google Chrome Beta" bundle, so hiding one
|
|
182
193
|
# process can leave the app-level active state on a sibling. Force
|
|
@@ -187,9 +198,23 @@ class _Worker(threading.Thread):
|
|
|
187
198
|
)
|
|
188
199
|
# NSApplicationActivateIgnoringOtherApps = 1 << 1
|
|
189
200
|
if prev is not None and not prev.isTerminated():
|
|
190
|
-
prev.activateWithOptions_(1 << 1)
|
|
191
|
-
|
|
192
|
-
|
|
201
|
+
refocused = bool(prev.activateWithOptions_(1 << 1))
|
|
202
|
+
# Action log (2026-08-06): "did the guard actually hide it" was
|
|
203
|
+
# unanswerable for every popup report — the guard acted silently.
|
|
204
|
+
# One line per suppression, same relay as the telemetry.
|
|
205
|
+
s4l_log_relay.emit(
|
|
206
|
+
f"[browser-foreground] guard hide pid={pid} hid={hid} "
|
|
207
|
+
f"refocused={refocused} prev={self._prev_app or '?'}",
|
|
208
|
+
context="browser-foreground",
|
|
209
|
+
)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
try:
|
|
212
|
+
s4l_log_relay.emit(
|
|
213
|
+
f"[browser-foreground] guard hide FAILED pid={pid} err={type(e).__name__}: {e}",
|
|
214
|
+
context="browser-foreground",
|
|
215
|
+
)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
193
218
|
|
|
194
219
|
def run(self):
|
|
195
220
|
while True:
|
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.8-rc.2",
|
|
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
|
@@ -97,6 +97,12 @@ def background_new_page(browser, context, url: str = "about:blank", timeout_ms:
|
|
|
97
97
|
try:
|
|
98
98
|
with context.expect_page(timeout=timeout_ms) as pg_info:
|
|
99
99
|
cdp.send("Target.createTarget", {"url": url, "background": True})
|
|
100
|
+
# Log SUCCESS too (2026-08-05): the residual focus-steal hunt hit
|
|
101
|
+
# same-second orphan blank creates that no log claimed. Success
|
|
102
|
+
# was silent by design, which made "our background create fired
|
|
103
|
+
# and coincided" indistinguishable from "an unknown foreground
|
|
104
|
+
# creator". One line per create closes that ambiguity.
|
|
105
|
+
_activity_log("bg_new_page", f"url={url[:60]}")
|
|
100
106
|
return pg_info.value
|
|
101
107
|
finally:
|
|
102
108
|
try:
|
|
@@ -113,18 +119,26 @@ def background_new_page(browser, context, url: str = "about:blank", timeout_ms:
|
|
|
113
119
|
f"({type(e).__name__}: {str(e)[:120]}); falling back to "
|
|
114
120
|
f"FOREGROUND new_page (focus steal likely)")
|
|
115
121
|
print(msg, file=sys.stderr)
|
|
116
|
-
|
|
117
|
-
_p = os.path.expanduser("~/.claude/browser-profiles/browser-activity.log")
|
|
118
|
-
with open(_p, "a") as _f:
|
|
119
|
-
import time as _t
|
|
120
|
-
_f.write(f"[{_t.strftime('%Y-%m-%d %H:%M:%S')}] pycdp "
|
|
121
|
-
f"script=browser_lifecycle.py action=fg_fallback "
|
|
122
|
-
f"pid={os.getpid()} detail={type(e).__name__}\n")
|
|
123
|
-
except Exception:
|
|
124
|
-
pass
|
|
122
|
+
_activity_log("fg_fallback", f"detail={type(e).__name__}")
|
|
125
123
|
return context.new_page()
|
|
126
124
|
|
|
127
125
|
|
|
126
|
+
def _activity_log(action: str, detail: str = "") -> None:
|
|
127
|
+
"""One line into the universal browser-activity.log (same file the
|
|
128
|
+
Python-CDP attach logging uses). Best-effort; never raises."""
|
|
129
|
+
try:
|
|
130
|
+
import time as _t
|
|
131
|
+
|
|
132
|
+
_p = os.path.expanduser("~/.claude/browser-profiles/browser-activity.log")
|
|
133
|
+
with open(_p, "a") as _f:
|
|
134
|
+
_f.write(f"[{_t.strftime('%Y-%m-%d %H:%M:%S')}] pycdp "
|
|
135
|
+
f"script=browser_lifecycle.py action={action} "
|
|
136
|
+
f"pid={os.getpid()} argv0={os.path.basename(sys.argv[0] or '?')} "
|
|
137
|
+
f"{detail}\n")
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
128
142
|
def register_park_on_exit(cdp_base: str, host_markers, park_url: str, label: str) -> None:
|
|
129
143
|
"""Arm park_tabs to run at process exit, once per (endpoint, park_url).
|
|
130
144
|
Call from a platform lib's get_browser_and_page so only processes that
|
package/scripts/claude_job.py
CHANGED
|
@@ -50,6 +50,11 @@ import s4l_env # noqa: E402 (lives next to this file in scripts/)
|
|
|
50
50
|
|
|
51
51
|
s4l_env.mirror()
|
|
52
52
|
|
|
53
|
+
# Draft-provider selection (2026-08-06): <state_dir>/draft-provider.json decides
|
|
54
|
+
# which engine answers queue-eligible tags. Stamped once at setup, read here at
|
|
55
|
+
# the seam; no env-var fallback layering. See scripts/draft_provider.py.
|
|
56
|
+
import draft_provider # noqa: E402 (lives next to this file in scripts/)
|
|
57
|
+
|
|
53
58
|
# Best-effort menu-bar activity narration. Importable because this script's own
|
|
54
59
|
# directory (scripts/) is on sys.path[0] when run as `python3 .../claude_job.py`.
|
|
55
60
|
# A failure to import (or to write) must NEVER affect the queue's real work.
|
|
@@ -702,6 +707,174 @@ def _plog(msg: str) -> None:
|
|
|
702
707
|
pass
|
|
703
708
|
|
|
704
709
|
|
|
710
|
+
def _emit_envelope(obj) -> None:
|
|
711
|
+
"""Print the claude `--output-format json` shaped envelope the pipeline's
|
|
712
|
+
raw_decode + structured_output/result parsers expect, byte-compatible with
|
|
713
|
+
both the queue path and a real claude run."""
|
|
714
|
+
envelope = {
|
|
715
|
+
"type": "result",
|
|
716
|
+
"subtype": "success",
|
|
717
|
+
"is_error": False,
|
|
718
|
+
"structured_output": obj,
|
|
719
|
+
"result": json.dumps(obj) if not isinstance(obj, str) else obj,
|
|
720
|
+
}
|
|
721
|
+
sys.stdout.write(json.dumps(envelope))
|
|
722
|
+
sys.stdout.flush()
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _run_codex_exec(ns, qtype: str, prompt: str, schema_text: str | None) -> int:
|
|
726
|
+
"""codex-exec provider: answer the turn inline via the ChatGPT-app-bundled
|
|
727
|
+
`codex exec` (headless, subscription-authenticated). No queue, no worker,
|
|
728
|
+
no app-open requirement. Same envelope, same 0/1/79 exit semantics as the
|
|
729
|
+
queue path, so run_claude.sh callers can't tell the difference.
|
|
730
|
+
|
|
731
|
+
No fallback to the queue on failure, by design: a broken codex login must
|
|
732
|
+
fail loudly (rc=1) so the stall/deadman rails surface it, not silently
|
|
733
|
+
strand jobs in a queue no worker drains.
|
|
734
|
+
"""
|
|
735
|
+
import tempfile
|
|
736
|
+
|
|
737
|
+
codex = draft_provider.codex_bin()
|
|
738
|
+
if not codex:
|
|
739
|
+
_plog(f"codex-exec provider active but no codex binary found; failing {qtype}")
|
|
740
|
+
return 1
|
|
741
|
+
|
|
742
|
+
batch = (os.environ.get("BATCH_ID") or os.environ.get("SA_CYCLE_ID") or "-").strip() or "-"
|
|
743
|
+
job_id = uuid.uuid4().hex
|
|
744
|
+
_arm_deathwatch(job_id, qtype, batch)
|
|
745
|
+
_act_write(qtype)
|
|
746
|
+
|
|
747
|
+
schema_path = None
|
|
748
|
+
out_path = None
|
|
749
|
+
try:
|
|
750
|
+
out_fd, out_path = tempfile.mkstemp(prefix="s4l_codex_out_", suffix=".txt")
|
|
751
|
+
os.close(out_fd)
|
|
752
|
+
cmd = [
|
|
753
|
+
codex, "exec", "-",
|
|
754
|
+
"-C", state_dir(),
|
|
755
|
+
"-s", "read-only",
|
|
756
|
+
"--skip-git-repo-check",
|
|
757
|
+
"-o", out_path,
|
|
758
|
+
]
|
|
759
|
+
# Deliberately NOT --output-schema: OpenAI's strict structured-output
|
|
760
|
+
# mode rejects our lenient claude-style schemas (400 invalid_json_schema
|
|
761
|
+
# unless additionalProperties:false + all-required everywhere). The
|
|
762
|
+
# schema rides the prompt as guidance instead, mirroring the worker
|
|
763
|
+
# rail, and _validate_against_schema stays the lenient gate.
|
|
764
|
+
if schema_text:
|
|
765
|
+
prompt = (
|
|
766
|
+
f"{prompt}\n\n"
|
|
767
|
+
"FINAL ANSWER FORMAT: reply with ONLY a JSON value matching this "
|
|
768
|
+
"JSON Schema. No prose, no markdown fences, nothing else:\n"
|
|
769
|
+
f"{schema_text}"
|
|
770
|
+
)
|
|
771
|
+
# Model default: gpt-5.6-sol (best 5.6 tier; user call 2026-08-06), env
|
|
772
|
+
# override via S4L_CODEX_MODEL. Sol availability is plan-gated and the
|
|
773
|
+
# app's models_cache.json under-reports it, so truth is probe-by-use:
|
|
774
|
+
# on the specific "model not supported" 400 we retry ONCE with no -m
|
|
775
|
+
# (the account's config default, e.g. gpt-5.6-terra) and log the
|
|
776
|
+
# downgrade loudly. Any other failure stays loud with no retry.
|
|
777
|
+
model = os.environ.get("S4L_CODEX_MODEL", "").strip() or "gpt-5.6-sol"
|
|
778
|
+
# The user's global config may pin xhigh reasoning (fine for coding,
|
|
779
|
+
# slow and token-hungry for a drafting turn). Default these turns to
|
|
780
|
+
# medium; S4L_CODEX_REASONING overrides.
|
|
781
|
+
effort = os.environ.get("S4L_CODEX_REASONING", "medium").strip() or "medium"
|
|
782
|
+
cmd += ["-c", f'model_reasoning_effort="{effort}"']
|
|
783
|
+
|
|
784
|
+
budget = max(60, ns.timeout - 60)
|
|
785
|
+
_plog(f"codex-exec start {qtype} job {job_id} batch={batch} bin={codex} model={model} effort={effort} timeout={budget}s")
|
|
786
|
+
started = time.time()
|
|
787
|
+
proc = None
|
|
788
|
+
for attempt_model in (model, None):
|
|
789
|
+
attempt_cmd = cmd + (["-m", attempt_model] if attempt_model else [])
|
|
790
|
+
try:
|
|
791
|
+
proc = subprocess.run(
|
|
792
|
+
attempt_cmd,
|
|
793
|
+
input=prompt,
|
|
794
|
+
capture_output=True,
|
|
795
|
+
text=True,
|
|
796
|
+
timeout=max(60, budget - int(time.time() - started)),
|
|
797
|
+
)
|
|
798
|
+
except subprocess.TimeoutExpired:
|
|
799
|
+
_act_clear()
|
|
800
|
+
_plog(f"codex-exec timed out after {budget}s on job {job_id} ({qtype})")
|
|
801
|
+
return 79 # mirror the queue path's "blocked, skip cleanly"
|
|
802
|
+
err_blob = f"{proc.stderr or ''}{proc.stdout or ''}"
|
|
803
|
+
if (
|
|
804
|
+
attempt_model
|
|
805
|
+
and "not supported when using Codex" in err_blob
|
|
806
|
+
):
|
|
807
|
+
_plog(
|
|
808
|
+
f"codex-exec model {attempt_model} unavailable on this account; "
|
|
809
|
+
f"retrying job {job_id} with the config default model"
|
|
810
|
+
)
|
|
811
|
+
continue
|
|
812
|
+
break
|
|
813
|
+
|
|
814
|
+
# Token usage rides stderr as "tokens used\nN"; best-effort log only.
|
|
815
|
+
m = re.search(r"tokens used\s*\n?\s*([\d,]+)", proc.stderr or "")
|
|
816
|
+
tokens = m.group(1) if m else "?"
|
|
817
|
+
if proc.returncode != 0:
|
|
818
|
+
tail = (proc.stderr or proc.stdout or "").strip()[-400:]
|
|
819
|
+
_act_clear()
|
|
820
|
+
_plog(f"codex-exec rc={proc.returncode} on job {job_id} ({qtype}); tail: {tail}")
|
|
821
|
+
return 1
|
|
822
|
+
|
|
823
|
+
try:
|
|
824
|
+
with open(out_path) as f:
|
|
825
|
+
text = f.read().strip()
|
|
826
|
+
except Exception as e:
|
|
827
|
+
_act_clear()
|
|
828
|
+
_plog(f"codex-exec produced no last-message file for job {job_id}: {e}")
|
|
829
|
+
return 1
|
|
830
|
+
if not text:
|
|
831
|
+
_act_clear()
|
|
832
|
+
_plog(f"codex-exec empty answer on job {job_id} ({qtype})")
|
|
833
|
+
return 1
|
|
834
|
+
|
|
835
|
+
# Parse the answer the same leniently the worker rail does: JSON when
|
|
836
|
+
# it parses (objects for schema'd jobs, bare strings for link-tail),
|
|
837
|
+
# fenced JSON unwrapped, otherwise the raw text as a plain string.
|
|
838
|
+
obj = None
|
|
839
|
+
parsed = False
|
|
840
|
+
for candidate in (text, re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()):
|
|
841
|
+
try:
|
|
842
|
+
obj = json.loads(candidate)
|
|
843
|
+
parsed = True
|
|
844
|
+
break
|
|
845
|
+
except Exception:
|
|
846
|
+
continue
|
|
847
|
+
if not parsed:
|
|
848
|
+
obj = text
|
|
849
|
+
|
|
850
|
+
err = _validate_against_schema(obj, schema_text)
|
|
851
|
+
if err:
|
|
852
|
+
_act_clear()
|
|
853
|
+
_plog(f"codex-exec result rejected for job {job_id} ({qtype}): {err}")
|
|
854
|
+
return 1
|
|
855
|
+
|
|
856
|
+
_emit_envelope(obj)
|
|
857
|
+
_mark_drain_success()
|
|
858
|
+
_stamp_heartbeat("codex-exec", qtype)
|
|
859
|
+
try:
|
|
860
|
+
_ncand = len(obj.get("candidates")) if isinstance(obj, dict) and isinstance(obj.get("candidates"), list) else "?"
|
|
861
|
+
except Exception:
|
|
862
|
+
_ncand = "?"
|
|
863
|
+
_plog(
|
|
864
|
+
f"codex-exec done job {job_id} batch={batch} ({qtype}) in {int(time.time() - started)}s "
|
|
865
|
+
f"tokens={tokens}; {_ncand} candidates -> producer assembles the plan"
|
|
866
|
+
)
|
|
867
|
+
return 0
|
|
868
|
+
finally:
|
|
869
|
+
for p in (schema_path, out_path):
|
|
870
|
+
if p:
|
|
871
|
+
try:
|
|
872
|
+
os.remove(p)
|
|
873
|
+
except OSError:
|
|
874
|
+
pass
|
|
875
|
+
_disarm_deathwatch(job_id)
|
|
876
|
+
|
|
877
|
+
|
|
705
878
|
def cmd_provider(ns) -> int:
|
|
706
879
|
_apply_state_dir_override(ns)
|
|
707
880
|
qtype = TAG_TO_TYPE.get(ns.tag)
|
|
@@ -748,10 +921,16 @@ def cmd_provider(ns) -> int:
|
|
|
748
921
|
# S4L paused: refuse to enqueue at all. Workers won't claim while paused, so
|
|
749
922
|
# an enqueued job would just sit in pending/ while this producer burns its
|
|
750
923
|
# whole wait window "drafting" and then dies to its external timeout.
|
|
924
|
+
# Applies to every provider: paused means no drafting, period.
|
|
751
925
|
if _is_paused():
|
|
752
926
|
_plog(f"S4L paused; refusing to enqueue {qtype} job")
|
|
753
927
|
return 1
|
|
754
928
|
|
|
929
|
+
# Provider branch (2026-08-06). codex-exec answers inline; claude-p never
|
|
930
|
+
# reaches here (eligible already said no); anything else is the queue.
|
|
931
|
+
if draft_provider.get() == "codex-exec":
|
|
932
|
+
return _run_codex_exec(ns, qtype, prompt, schema_text)
|
|
933
|
+
|
|
755
934
|
job_id = uuid.uuid4().hex
|
|
756
935
|
created = time.time()
|
|
757
936
|
# Cycle batch id (run-twitter-cycle.sh exports BATCH_ID) so every provider.log
|
|
@@ -1203,8 +1382,18 @@ def cmd_result(ns) -> int:
|
|
|
1203
1382
|
|
|
1204
1383
|
def cmd_eligible(ns: argparse.Namespace) -> int:
|
|
1205
1384
|
"""Routing probe for run_claude.sh: exit 0 when the tag is queue-mapped,
|
|
1206
|
-
1 otherwise. TAG_TO_TYPE is the single routing truth — no env var.
|
|
1207
|
-
|
|
1385
|
+
1 otherwise. TAG_TO_TYPE is the single routing truth — no env var.
|
|
1386
|
+
|
|
1387
|
+
Provider layer (2026-08-06): when the box's draft provider is `claude-p`,
|
|
1388
|
+
every tag reports NOT eligible, so run_claude.sh's existing fall-through
|
|
1389
|
+
runs the real `claude -p` directly. That IS the claude-p implementation:
|
|
1390
|
+
zero new execution paths, the locked wrapper stays untouched.
|
|
1391
|
+
"""
|
|
1392
|
+
if ns.tag not in TAG_TO_TYPE:
|
|
1393
|
+
return 1
|
|
1394
|
+
if draft_provider.get() == "claude-p":
|
|
1395
|
+
return 1
|
|
1396
|
+
return 0
|
|
1208
1397
|
|
|
1209
1398
|
|
|
1210
1399
|
def main() -> int:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""draft_provider.py: which engine answers the pipeline's pure text->JSON turns.
|
|
3
|
+
|
|
4
|
+
One file in the state dir (<state_dir>/draft-provider.json) records the choice,
|
|
5
|
+
stamped once at setup time and read at the run_claude.sh seam (via
|
|
6
|
+
claude_job.py). No env-var fallback layering: the file is the single source of
|
|
7
|
+
truth, matching the experiments stamp-at-source rule.
|
|
8
|
+
|
|
9
|
+
Providers:
|
|
10
|
+
claude-desktop-queue (default) enqueue for the Claude Desktop s4l-worker
|
|
11
|
+
scheduled task; the only option on boxes whose sole
|
|
12
|
+
authenticated engine is Claude Desktop itself.
|
|
13
|
+
claude-p skip the queue entirely; claude_job.py `eligible`
|
|
14
|
+
returns 1 so run_claude.sh falls through to the real
|
|
15
|
+
`claude -p` it already knows how to run. Requires the
|
|
16
|
+
Claude Code CLI, logged in.
|
|
17
|
+
codex-exec answer inline via the ChatGPT-app-bundled `codex exec`
|
|
18
|
+
(headless, subscription-authenticated). Requires the
|
|
19
|
+
ChatGPT app installed and logged in. No queue, no
|
|
20
|
+
worker, no app-open requirement.
|
|
21
|
+
|
|
22
|
+
CLI:
|
|
23
|
+
python3 scripts/draft_provider.py get -> prints the active provider
|
|
24
|
+
python3 scripts/draft_provider.py set <name> -> records the choice
|
|
25
|
+
python3 scripts/draft_provider.py codex-bin -> prints resolved codex path
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
VALID_PROVIDERS = ("claude-desktop-queue", "claude-p", "codex-exec")
|
|
36
|
+
DEFAULT_PROVIDER = "claude-desktop-queue"
|
|
37
|
+
|
|
38
|
+
# The npm @openai/codex install is unreliable on macOS (XProtect false-positives
|
|
39
|
+
# trash its native binary, openai/codex #31377), so the ChatGPT-app-bundled
|
|
40
|
+
# binary is the only path we trust. S4L_CODEX_BIN exists for tests and for
|
|
41
|
+
# machines that keep the binary elsewhere.
|
|
42
|
+
CODEX_APP_BIN = "/Applications/ChatGPT.app/Contents/Resources/codex"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def state_dir() -> str:
|
|
46
|
+
return os.environ.get("S4L_STATE_DIR") or os.path.join(
|
|
47
|
+
os.path.expanduser("~"), ".social-autoposter-mcp"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def provider_path() -> str:
|
|
52
|
+
return os.path.join(state_dir(), "draft-provider.json")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get() -> str:
|
|
56
|
+
try:
|
|
57
|
+
with open(provider_path()) as f:
|
|
58
|
+
v = json.load(f).get("provider", "")
|
|
59
|
+
except Exception:
|
|
60
|
+
return DEFAULT_PROVIDER
|
|
61
|
+
return v if v in VALID_PROVIDERS else DEFAULT_PROVIDER
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_provider(name: str, set_by: str = "cli") -> None:
|
|
65
|
+
if name not in VALID_PROVIDERS:
|
|
66
|
+
raise ValueError(f"unknown provider {name!r}; valid: {VALID_PROVIDERS}")
|
|
67
|
+
os.makedirs(state_dir(), exist_ok=True)
|
|
68
|
+
tmp = provider_path() + ".tmp"
|
|
69
|
+
with open(tmp, "w") as f:
|
|
70
|
+
json.dump(
|
|
71
|
+
{
|
|
72
|
+
"provider": name,
|
|
73
|
+
"set_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
74
|
+
"set_by": set_by,
|
|
75
|
+
},
|
|
76
|
+
f,
|
|
77
|
+
indent=2,
|
|
78
|
+
)
|
|
79
|
+
os.replace(tmp, provider_path())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def codex_bin() -> str | None:
|
|
83
|
+
"""Resolved codex binary path, or None when nothing usable exists."""
|
|
84
|
+
override = os.environ.get("S4L_CODEX_BIN", "").strip()
|
|
85
|
+
if override and os.access(override, os.X_OK):
|
|
86
|
+
return override
|
|
87
|
+
if os.access(CODEX_APP_BIN, os.X_OK):
|
|
88
|
+
return CODEX_APP_BIN
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> int:
|
|
93
|
+
if len(sys.argv) < 2 or sys.argv[1] not in ("get", "set", "codex-bin"):
|
|
94
|
+
print(__doc__, file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
cmd = sys.argv[1]
|
|
97
|
+
if cmd == "get":
|
|
98
|
+
print(get())
|
|
99
|
+
return 0
|
|
100
|
+
if cmd == "codex-bin":
|
|
101
|
+
b = codex_bin()
|
|
102
|
+
if not b:
|
|
103
|
+
print("codex binary not found (is the ChatGPT app installed?)", file=sys.stderr)
|
|
104
|
+
return 1
|
|
105
|
+
print(b)
|
|
106
|
+
return 0
|
|
107
|
+
if len(sys.argv) < 3:
|
|
108
|
+
print("usage: draft_provider.py set <name>", file=sys.stderr)
|
|
109
|
+
return 2
|
|
110
|
+
try:
|
|
111
|
+
set_provider(sys.argv[2])
|
|
112
|
+
except ValueError as e:
|
|
113
|
+
print(str(e), file=sys.stderr)
|
|
114
|
+
return 2
|
|
115
|
+
print(f"draft provider set to {sys.argv[2]}")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Diagnosis-window capture (2026-08-06): on every harness focus-steal event,
|
|
3
|
+
# snapshot the system state THAT SECOND — transient CDP clients, process ages,
|
|
4
|
+
# frontmost app, and the AppleEvents debug log (enabled via `sudo log config`)
|
|
5
|
+
# which names the PROCESS that requested Chrome's activation. Everything we
|
|
6
|
+
# could never reconstruct minutes later.
|
|
7
|
+
#
|
|
8
|
+
# Run under nohup during the diagnosis window; not a pipeline component.
|
|
9
|
+
# Output: skill/logs/foreground-captures.log
|
|
10
|
+
LOG="$HOME/.social-autoposter-mcp/menubar/menubar.err.log"
|
|
11
|
+
OUT="$HOME/social-autoposter/skill/logs/foreground-captures.log"
|
|
12
|
+
|
|
13
|
+
tail -F -n0 "$LOG" 2>/dev/null | while IFS= read -r line; do
|
|
14
|
+
case "$line" in
|
|
15
|
+
*harness_browser_foregrounded*) ;;
|
|
16
|
+
*) continue ;;
|
|
17
|
+
esac
|
|
18
|
+
{
|
|
19
|
+
echo "===================================================================="
|
|
20
|
+
echo "CAPTURE $(date -u +%FT%T.%3NZ 2>/dev/null || date -u +%FT%TZ)"
|
|
21
|
+
echo "TRIGGER: $line"
|
|
22
|
+
echo "--- cdp clients 9557/9555/9556 ---"
|
|
23
|
+
lsof -nP -i :9557 -i :9555 -i :9556 2>/dev/null | grep -v 'Google Chrome' | grep -v COMMAND | \
|
|
24
|
+
while read -r c p rest; do
|
|
25
|
+
echo "$c $p started=$(ps -p "$p" -o lstart= 2>/dev/null | tr -s ' ') cmd=$(ps -p "$p" -o command= 2>/dev/null | cut -c1-140)"
|
|
26
|
+
done
|
|
27
|
+
echo "--- frontmost ---"
|
|
28
|
+
osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null
|
|
29
|
+
echo "--- appleevents debug (last 30s, chrome-related) ---"
|
|
30
|
+
log show --last 30s --predicate 'subsystem == "com.apple.appleevents"' --style compact 2>/dev/null | \
|
|
31
|
+
grep -iE 'chrome|activate|reopen|open' | head -20
|
|
32
|
+
echo "--- launchservices (last 30s, chrome-related) ---"
|
|
33
|
+
log show --last 30s --predicate 'subsystem == "com.apple.launchservices"' --style compact 2>/dev/null | \
|
|
34
|
+
grep -iE 'chrome|front|activate' | head -12
|
|
35
|
+
echo "--- fresh short-lived python (started last 3 min) ---"
|
|
36
|
+
for p in $(pgrep -f python 2>/dev/null); do
|
|
37
|
+
et=$(ps -p "$p" -o etime= 2>/dev/null | tr -d ' ')
|
|
38
|
+
case "$et" in
|
|
39
|
+
0[0-2]:*|[0-9]:[0-9][0-9]) echo "$p etime=$et $(ps -p "$p" -o command= 2>/dev/null | cut -c1-120)" ;;
|
|
40
|
+
esac
|
|
41
|
+
done
|
|
42
|
+
echo ""
|
|
43
|
+
} >> "$OUT" 2>&1
|
|
44
|
+
done
|