@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 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
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.7",
3
- "installedAt": "2026-08-04T18:33:55.672Z"
2
+ "version": "1.7.8-rc.2",
3
+ "installedAt": "2026-08-07T01:31:32.984Z"
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.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
- ra.hide() # order the harness window out
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
- except Exception:
192
- pass
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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.7",
3
+ "version": "1.7.8-rc.2",
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",
@@ -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
- try:
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
@@ -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
- return 0 if ns.tag in TAG_TO_TYPE else 1
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