@m13v/s4l 1.7.8-rc.3 → 1.7.9-rc.1
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 +82 -35
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -5
- package/mcp/menubar/s4l_browser_foreground.py +8 -4
- package/mcp/menubar/s4l_menubar.py +84 -26
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +87 -20
- package/scripts/install.sh +35 -0
package/mcp/dist/index.js
CHANGED
|
@@ -333,7 +333,8 @@ const server = new McpServer({
|
|
|
333
333
|
"anything; X is a reasonable default to SUGGEST, but do not connect it, or any platform, without " +
|
|
334
334
|
"the user's answer. Connect exactly what they choose via project_config action:'connect_x' " +
|
|
335
335
|
"and/or action:'connect_reddit'), AND " +
|
|
336
|
-
"the
|
|
336
|
+
"the draft autopilot has been armed via queue_setup (on Claude hosts that means its scheduled " +
|
|
337
|
+
"tasks are created and firing; on this app's host queue_setup alone completes it) " +
|
|
337
338
|
"(see SCHEDULE THE AUTOPILOT below). The owned runtime (private Python + Chromium) provisions " +
|
|
338
339
|
"ITSELF automatically on boot — you do NOT install it. Just poll runtime action:'status' until it " +
|
|
339
340
|
"reports ready; only call runtime action:'install' or action:'doctor' to TROUBLESHOOT if status " +
|
|
@@ -350,8 +351,8 @@ const server = new McpServer({
|
|
|
350
351
|
"DO schedule the draft autopilot as the final step (once running it only queues drafts for " +
|
|
351
352
|
"approval, it never posts on its own). When setup reaches done (" +
|
|
352
353
|
"runtime ready, a project configured with seeded topics, at least one platform connected, and the " +
|
|
353
|
-
"
|
|
354
|
-
"
|
|
354
|
+
"draft autopilot " +
|
|
355
|
+
"armed via queue_setup), call the `dashboard` tool so the user " +
|
|
355
356
|
"sees the finished setup rendered, then give " +
|
|
356
357
|
"the completion summary.\n\n" +
|
|
357
358
|
"SCHEDULE THE AUTOPILOT (final onboarding step). There is ONE drafting pipeline: a launchd " +
|
|
@@ -360,18 +361,24 @@ const server = new McpServer({
|
|
|
360
361
|
"menu-bar approval cards. Drafting is the queue + worker path, fired automatically by a launchd " +
|
|
361
362
|
"kicker this server installs (every few minutes) — it runs on its own once the tasks exist; " +
|
|
362
363
|
"onboarding does NOT need to wait for or verify a draft. Steps, in order: " +
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
364
|
+
(s4lHost() === "codex"
|
|
365
|
+
? "(1) call `queue_setup` once — on THIS host it arms the autopilot by itself (drafting runs " +
|
|
366
|
+
"inline through this app's engine) and returns no tasks; do NOT create any scheduled task or " +
|
|
367
|
+
"automation; (2) call the `dashboard` tool to confirm schedule_state 'ok', then summarize. " +
|
|
368
|
+
"Do NOT ask the user to review drafts or pause for input mid-onboarding; once queue_setup " +
|
|
369
|
+
"returns and the dashboard reads ok, setup is done.\n\n"
|
|
370
|
+
: "(1) call `queue_setup` — it pre-writes the worker task prompt and returns the task spec " +
|
|
371
|
+
"(s4l-worker, the universal queue worker); " +
|
|
372
|
+
"(2) for EACH returned spec, call the HOST tool `create_scheduled_task` with its taskId, cronExpression, " +
|
|
373
|
+
"prompt, and notifyOnCompletion VERBATIM — notifyOnCompletion MUST be false, or the per-minute worker " +
|
|
374
|
+
"pops a 'no jobs' notification every run (if it says the task already exists, that's fine — do not " +
|
|
375
|
+
"recreate, but DO call `update_scheduled_task` with the same taskId and notifyOnCompletion:false to " +
|
|
376
|
+
"silence an older install's per-run notifications, then move on); " +
|
|
377
|
+
"(3) the launchd kicker + scheduled tasks now run the pipeline on their own — call the `dashboard` " +
|
|
378
|
+
"tool to confirm the schedule is firing (schedule_state 'ok'), then summarize. Do NOT create the " +
|
|
379
|
+
"old 'social-autoposter-autopilot' task — it is deprecated. Do NOT ask the user to review drafts " +
|
|
380
|
+
"or pause for input mid-onboarding; once the tasks are created and firing, setup is done. If the " +
|
|
381
|
+
"host exposes no create_scheduled_task tool, tell the user the autopilot couldn't be scheduled here.\n\n") +
|
|
375
382
|
"REDDIT IS AN EQUAL PLATFORM CHOICE, ASKED ALONGSIDE X. Setup is complete with AT LEAST ONE " +
|
|
376
383
|
"platform connected: X-only, Reddit-only, or both — whichever the user picked when you asked " +
|
|
377
384
|
"up front. If they chose both, connect them one at a time (either order) in the same setup " +
|
|
@@ -2141,12 +2148,9 @@ tool("engagement_mode", {
|
|
|
2141
2148
|
: (personaQueryCount > 0
|
|
2142
2149
|
? `Personal-brand lane is on (the default); the persona is provisioned, topic-seeded, and ${personaQueryCount} search quer${personaQueryCount === 1 ? "y" : "ies"} seeded, so there `
|
|
2143
2150
|
: "Personal-brand lane is on (the default); the persona is provisioned + topic-seeded (but NO search_queries were supplied, so it will run one topic-as-query at a time — re-call engagement_mode action:'set' with a search_queries array of ~30 X search strings expanded from the persona topics to fan out). There ") +
|
|
2144
|
-
"is nothing more to configure (no product project is needed). NOW SCHEDULE THE AUTOPILOT:
|
|
2145
|
-
|
|
2146
|
-
"
|
|
2147
|
-
"call update_scheduled_task with notifyOnCompletion:false to silence per-run notifications), " +
|
|
2148
|
-
"then call the dashboard tool to confirm the schedule is firing. " +
|
|
2149
|
-
"That is the final setup step — do NOT stop before the autopilot is scheduled.";
|
|
2151
|
+
"is nothing more to configure (no product project is needed). NOW SCHEDULE THE AUTOPILOT: " +
|
|
2152
|
+
scheduleAutopilotSteps() + ". " +
|
|
2153
|
+
"That is the final setup step — do NOT stop before the autopilot is armed.";
|
|
2150
2154
|
return jsonContent({
|
|
2151
2155
|
ok: true,
|
|
2152
2156
|
flags: { personal_brand: personalBrand, promotion },
|
|
@@ -2718,7 +2722,7 @@ tool("project_config", {
|
|
|
2718
2722
|
? (anyPlatform
|
|
2719
2723
|
? "All configured projects are ready and a platform is connected" +
|
|
2720
2724
|
(xConnected ? "" : " (Reddit; X was skipped, which is fine)") +
|
|
2721
|
-
". SCHEDULE THE AUTOPILOT:
|
|
2725
|
+
". SCHEDULE THE AUTOPILOT: " + scheduleAutopilotSteps() + " — that is the terminal step; do NOT wait for or verify a draft card. Do NOT pause to ask the user to review drafts."
|
|
2722
2726
|
: "All configured projects are ready, but NO platform is connected yet; posting needs a logged-in session. X is the default: detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed. If the user prefers Reddit or X can't be connected, run action:'connect_reddit' instead; ONE connected platform completes setup.")
|
|
2723
2727
|
: "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call project_config again. Ask only if a required field is genuinely unknowable." +
|
|
2724
2728
|
(anyPlatform ? "" : " No platform is connected yet either; X is the default (connect_x with confirm:true), or connect_reddit if the user prefers Reddit."),
|
|
@@ -2850,11 +2854,9 @@ tool("project_config", {
|
|
|
2850
2854
|
: result.ready
|
|
2851
2855
|
? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
|
|
2852
2856
|
`detect sources, warn about keychain prompts, and call project_config with ` +
|
|
2853
|
-
`action:'connect_x', confirm:true immediately. Once X is connected, schedule the autopilot ` +
|
|
2854
|
-
|
|
2855
|
-
`
|
|
2856
|
-
`dashboard to confirm the schedule is firing (schedule_state 'ok') — that is the final step, ` +
|
|
2857
|
-
`no need to wait for or verify a draft card.`
|
|
2857
|
+
`action:'connect_x', confirm:true immediately. Once X is connected, schedule the autopilot: ` +
|
|
2858
|
+
scheduleAutopilotSteps() +
|
|
2859
|
+
` — that is the final step, no need to wait for or verify a draft card.`
|
|
2858
2860
|
: `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
|
|
2859
2861
|
`First derive those fields from existing context, profile_scan, and website research, then ` +
|
|
2860
2862
|
`call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
|
|
@@ -3607,6 +3609,21 @@ function runtimeSnapshot() {
|
|
|
3607
3609
|
function s4lHost() {
|
|
3608
3610
|
return (process.env.S4L_HOST || "").trim().toLowerCase() === "codex" ? "codex" : "claude";
|
|
3609
3611
|
}
|
|
3612
|
+
// Host-aware "schedule the autopilot" step text, shared by every onboarding
|
|
3613
|
+
// guidance string. Codex/ChatGPT hosts have no create_scheduled_task tool and
|
|
3614
|
+
// need none: queue_setup alone arms the autopilot there (drafting runs inline
|
|
3615
|
+
// through the app's engine). Hoisted function so module-level instruction
|
|
3616
|
+
// strings can call it during load.
|
|
3617
|
+
function scheduleAutopilotSteps() {
|
|
3618
|
+
return s4lHost() === "codex"
|
|
3619
|
+
? "call queue_setup once — on this host it arms the draft autopilot by itself (drafting runs " +
|
|
3620
|
+
"inline through this app's engine) and returns NO tasks; do NOT create any scheduled task or " +
|
|
3621
|
+
"automation — then call the dashboard tool to confirm schedule_state 'ok'"
|
|
3622
|
+
: "call queue_setup and create each returned task with create_scheduled_task (prompt and " +
|
|
3623
|
+
"notifyOnCompletion verbatim — notifyOnCompletion must be false; 'already exists' is fine, but then " +
|
|
3624
|
+
"call update_scheduled_task with notifyOnCompletion:false to silence per-run notifications), " +
|
|
3625
|
+
"then call the dashboard tool to confirm the schedule is firing (schedule_state 'ok')";
|
|
3626
|
+
}
|
|
3610
3627
|
// Stamp <state_dir>/draft-provider.json, the single source of truth read at the
|
|
3611
3628
|
// run_claude.sh seam (scripts/draft_provider.py / claude_job.py) for which
|
|
3612
3629
|
// engine answers the pipeline's pure text->JSON drafting turns. Last setup
|
|
@@ -3619,14 +3636,27 @@ function writeDraftProvider(provider, setBy) {
|
|
|
3619
3636
|
console.error(`[draft-provider] ${provider} (${setBy})`);
|
|
3620
3637
|
}
|
|
3621
3638
|
tool("queue_setup", {
|
|
3622
|
-
|
|
3623
|
-
description
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
"
|
|
3629
|
-
"
|
|
3639
|
+
// Title/description are host-aware (2026-08-06): an agent reads the tool
|
|
3640
|
+
// description FIRST, so it must tell the truth for the host it's on.
|
|
3641
|
+
// Historic name kept for fleet compatibility; conceptually this tool is
|
|
3642
|
+
// "arm the drafting engine" — Claude hosts get queue-worker task specs,
|
|
3643
|
+
// Codex/ChatGPT hosts get the provider stamped with nothing to schedule.
|
|
3644
|
+
title: s4lHost() === "codex"
|
|
3645
|
+
? "Arm the draft autopilot"
|
|
3646
|
+
: "Get autopilot scheduled-task specs",
|
|
3647
|
+
description: s4lHost() === "codex"
|
|
3648
|
+
? "Arms the hands-free draft autopilot on this machine. On this host there is NOTHING to " +
|
|
3649
|
+
"schedule: drafting runs inline through this app's engine, so ONE call completes autopilot " +
|
|
3650
|
+
"setup (it records the draft engine choice and returns no tasks). Do NOT create any " +
|
|
3651
|
+
"scheduled task or automation. The pipeline itself is kicked by launchd jobs this server " +
|
|
3652
|
+
"installs. Use this as the final onboarding step."
|
|
3653
|
+
: "Returns the scheduled task that runs the hands-free draft autopilot on this machine " +
|
|
3654
|
+
"(s4l-worker, the universal queue worker). For EACH returned task, call the host tool " +
|
|
3655
|
+
"create_scheduled_task with its taskId, cronExpression, prompt, and notifyOnCompletion " +
|
|
3656
|
+
"VERBATIM (do not edit the prompt — it contains exact local paths; notifyOnCompletion MUST be " +
|
|
3657
|
+
"false or the per-minute worker spams a notification every run). The task drains the local job queue that the " +
|
|
3658
|
+
"real pipeline feeds (all job types); the pipeline itself is kicked by launchd jobs this server " +
|
|
3659
|
+
"installs. Use this as the final onboarding step instead of the old per-type worker tasks.",
|
|
3630
3660
|
inputSchema: {},
|
|
3631
3661
|
}, async () => {
|
|
3632
3662
|
// Codex/ChatGPT host: there is no scheduled-task worker at all. Drafting
|
|
@@ -5200,6 +5230,23 @@ function startLocalPanel() {
|
|
|
5200
5230
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
5201
5231
|
localPanel = { url: `http://127.0.0.1:${port}/`, server: srv };
|
|
5202
5232
|
writePanelUrl(localPanel.url);
|
|
5233
|
+
// Periodic reclaim (2026-08-06, Codex port): on ChatGPT/Codex hosts the
|
|
5234
|
+
// app spawns a server per chat session and can kill it without the clean
|
|
5235
|
+
// relinquish (SIGKILL leaves panel-endpoint.json pointing at a dead pid).
|
|
5236
|
+
// Long-lived instances (the launchd keepalive server, a Desktop-hosted
|
|
5237
|
+
// one) re-run writePanelUrl on a slow tick: it cedes to any LIVE
|
|
5238
|
+
// registrant and only takes over dead/absent entries, so the shared file
|
|
5239
|
+
// self-heals within minutes instead of stranding menu-bar approvals.
|
|
5240
|
+
const reclaim = setInterval(() => {
|
|
5241
|
+
try {
|
|
5242
|
+
if (localPanel)
|
|
5243
|
+
writePanelUrl(localPanel.url);
|
|
5244
|
+
}
|
|
5245
|
+
catch {
|
|
5246
|
+
/* best-effort */
|
|
5247
|
+
}
|
|
5248
|
+
}, 120_000);
|
|
5249
|
+
reclaim.unref?.();
|
|
5203
5250
|
resolve(localPanel.url);
|
|
5204
5251
|
});
|
|
5205
5252
|
});
|
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.9-rc.1",
|
|
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": {
|
|
@@ -72,10 +72,6 @@
|
|
|
72
72
|
"name": "client_event",
|
|
73
73
|
"description": "Log a lightweight client UI event"
|
|
74
74
|
},
|
|
75
|
-
{
|
|
76
|
-
"name": "queue_setup",
|
|
77
|
-
"description": "Get autopilot scheduled-task specs"
|
|
78
|
-
},
|
|
79
75
|
{
|
|
80
76
|
"name": "dashboard",
|
|
81
77
|
"description": "S4L dashboard"
|
|
@@ -157,12 +157,16 @@ class _Worker(threading.Thread):
|
|
|
157
157
|
# focus back to the app the user was in. Screenshots/clicks are
|
|
158
158
|
# unaffected (CDP is offscreen-raster + synthetic input; the occlusion
|
|
159
159
|
# flags keep hidden tabs painting).
|
|
160
|
-
# DEFAULT
|
|
161
|
-
#
|
|
162
|
-
#
|
|
160
|
+
# DEFAULT ON (user decision 2026-08-07, reverses the 2026-07-30 default-off
|
|
161
|
+
# call): the guard hides an offscreen harness that steals the foreground
|
|
162
|
+
# unless explicitly opted out. Two knobs, by design:
|
|
163
|
+
# S4L_NO_HARNESS_HIDE -> HARD override, force off, checked FIRST so it
|
|
164
|
+
# always wins (this is the one-time interactive-login escape hatch).
|
|
165
|
+
# S4L_HARNESS_HIDE=0 -> SOFT opt-out for a machine that wants it off.
|
|
166
|
+
# Unset or any non-"0" value => hide is active (the new default).
|
|
163
167
|
if os.environ.get("S4L_NO_HARNESS_HIDE"):
|
|
164
168
|
return
|
|
165
|
-
if os.environ.get("S4L_HARNESS_HIDE")
|
|
169
|
+
if os.environ.get("S4L_HARNESS_HIDE", "1") == "0":
|
|
166
170
|
return
|
|
167
171
|
pos = details.get("window_position") or ""
|
|
168
172
|
try:
|
|
@@ -1749,7 +1749,7 @@ class S4LMenuBar(rumps.App):
|
|
|
1749
1749
|
self._spin_i = 0
|
|
1750
1750
|
self._spinner = rumps.Timer(self._spin, 0.12)
|
|
1751
1751
|
self._spinner.start()
|
|
1752
|
-
self._notify("S4L", "Updating…
|
|
1752
|
+
self._notify("S4L", f"Updating… {self._update_host_word()} will restart in a moment.")
|
|
1753
1753
|
threading.Thread(target=self._mcpb_update_work, daemon=True).start()
|
|
1754
1754
|
self._tick(None)
|
|
1755
1755
|
|
|
@@ -1783,6 +1783,45 @@ class S4LMenuBar(rumps.App):
|
|
|
1783
1783
|
pass
|
|
1784
1784
|
return dirs
|
|
1785
1785
|
|
|
1786
|
+
# ---- install-flavor detection (2026-08-06, Codex/ChatGPT port) ---------
|
|
1787
|
+
# A box can carry either or both server installs: the Claude Desktop
|
|
1788
|
+
# extension dir (.mcpb double-click / npm lanes) and the host-neutral
|
|
1789
|
+
# bundle at <state>/app (curl-lane install for the ChatGPT/Codex app).
|
|
1790
|
+
# The updater refreshes every install that exists and restarts only the
|
|
1791
|
+
# host apps that correspond to them.
|
|
1792
|
+
def _has_claude_ext(self):
|
|
1793
|
+
return os.path.isdir(self._ext_dir())
|
|
1794
|
+
|
|
1795
|
+
@staticmethod
|
|
1796
|
+
def _app_bundle_dir():
|
|
1797
|
+
return os.path.join(st.state_dir(), "app")
|
|
1798
|
+
|
|
1799
|
+
def _has_app_bundle(self):
|
|
1800
|
+
return os.path.isfile(os.path.join(self._app_bundle_dir(), "dist", "index.js"))
|
|
1801
|
+
|
|
1802
|
+
def _update_host_word(self):
|
|
1803
|
+
"""Which host app the update flow will restart, for user-facing copy."""
|
|
1804
|
+
return "Claude Desktop" if self._has_claude_ext() else "ChatGPT"
|
|
1805
|
+
|
|
1806
|
+
@staticmethod
|
|
1807
|
+
def _chatgpt_running():
|
|
1808
|
+
try:
|
|
1809
|
+
return subprocess.run(["pgrep", "-x", "ChatGPT"],
|
|
1810
|
+
capture_output=True, timeout=10).returncode == 0
|
|
1811
|
+
except Exception:
|
|
1812
|
+
return False
|
|
1813
|
+
|
|
1814
|
+
def _restart_chatgpt_if_running(self):
|
|
1815
|
+
"""Bounce the ChatGPT app so its next MCP spawn runs the refreshed
|
|
1816
|
+
bundle. Only when it was already running: if it's closed there is
|
|
1817
|
+
nothing stale to replace (codex exec and the next app launch pick up
|
|
1818
|
+
the new files on their own)."""
|
|
1819
|
+
if not self._chatgpt_running():
|
|
1820
|
+
return
|
|
1821
|
+
subprocess.run(["killall", "ChatGPT"], capture_output=True)
|
|
1822
|
+
time.sleep(3)
|
|
1823
|
+
subprocess.run(["open", "-a", "ChatGPT"], capture_output=True, timeout=20)
|
|
1824
|
+
|
|
1786
1825
|
@staticmethod
|
|
1787
1826
|
def _claude_running():
|
|
1788
1827
|
"""True while a Claude Desktop main process is alive. pgrep -x matches
|
|
@@ -1862,13 +1901,26 @@ class S4LMenuBar(rumps.App):
|
|
|
1862
1901
|
self._sig = None
|
|
1863
1902
|
self._notify("S4L update failed", "Couldn't download the update — check your connection.")
|
|
1864
1903
|
return
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1904
|
+
# Refresh every install flavor present on this box (Claude
|
|
1905
|
+
# extension dir, ChatGPT/Codex app bundle, or both). Unzipping
|
|
1906
|
+
# into a flavor that isn't installed would fabricate a stray
|
|
1907
|
+
# install, so target only what exists; a box with neither keeps
|
|
1908
|
+
# the legacy behavior (ext dir) as the recovery path.
|
|
1909
|
+
has_ext = self._has_claude_ext()
|
|
1910
|
+
has_app = self._has_app_bundle()
|
|
1911
|
+
targets = [d for d, ok in ((self._ext_dir(), has_ext),
|
|
1912
|
+
(self._app_bundle_dir(), has_app)) if ok]
|
|
1913
|
+
if not targets:
|
|
1914
|
+
targets = [self._ext_dir()]
|
|
1915
|
+
has_ext = True
|
|
1916
|
+
for tdir in targets:
|
|
1917
|
+
r = subprocess.run(["unzip", "-oq", mcpb, "-d", tdir],
|
|
1918
|
+
capture_output=True, timeout=180)
|
|
1919
|
+
if r.returncode != 0:
|
|
1920
|
+
self._updating_mcpb = False
|
|
1921
|
+
self._sig = None
|
|
1922
|
+
self._notify("S4L update failed", "Couldn't unpack the update.")
|
|
1923
|
+
return
|
|
1872
1924
|
# Record what we just installed so the tick loop can verify the
|
|
1873
1925
|
# EFFECTIVE version actually advanced after the restart. The old
|
|
1874
1926
|
# flow claimed success unconditionally, which lied on boxes whose
|
|
@@ -1876,7 +1928,7 @@ class S4LMenuBar(rumps.App):
|
|
|
1876
1928
|
# extension dir updated but the running install stayed old.
|
|
1877
1929
|
target = ""
|
|
1878
1930
|
try:
|
|
1879
|
-
with open(os.path.join(
|
|
1931
|
+
with open(os.path.join(targets[0], "manifest.json")) as f:
|
|
1880
1932
|
target = str((json.load(f) or {}).get("version") or "")
|
|
1881
1933
|
except Exception:
|
|
1882
1934
|
target = ""
|
|
@@ -1886,36 +1938,42 @@ class S4LMenuBar(rumps.App):
|
|
|
1886
1938
|
json.dump({"target": target, "started_at": time.time()}, f)
|
|
1887
1939
|
except Exception:
|
|
1888
1940
|
pass
|
|
1889
|
-
# Restart
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1941
|
+
# Restart whichever host apps correspond to the refreshed installs
|
|
1942
|
+
# (we're decoupled from both).
|
|
1943
|
+
if has_ext:
|
|
1944
|
+
self._quit_claude_and_wait()
|
|
1945
|
+
# Claude is fully down now — relocate the autopilot scheduled tasks'
|
|
1946
|
+
# cwd so their once-a-minute runs stop flooding the user's interactive
|
|
1947
|
+
# `claude --resume` history. MUST happen while Claude is down (it caches
|
|
1948
|
+
# the registry in memory and clobbers live edits). See queueWorkerCwd()
|
|
1949
|
+
# in mcp/src/index.ts and the same routine in scripts/s4l_box_update.sh.
|
|
1950
|
+
# _fresh (not the in-process call): this is a mid-update self-heal,
|
|
1951
|
+
# so it must run the code that was JUST downloaded, not whatever
|
|
1952
|
+
# this already-running (about to be replaced) process imported at
|
|
1953
|
+
# ITS OWN startup — see _rewrite_scheduled_task_cwd_fresh's docstring.
|
|
1954
|
+
self._rewrite_scheduled_task_cwd_fresh()
|
|
1901
1955
|
if target:
|
|
1902
1956
|
# The graceful quit can eat minutes; restart the verify clock
|
|
1903
|
-
# now that
|
|
1957
|
+
# now that the host is actually down so UPDATE_VERIFY_GRACE_SEC
|
|
1904
1958
|
# measures server boot, not app teardown.
|
|
1905
1959
|
try:
|
|
1906
1960
|
with open(self._update_verify_path(), "w") as f:
|
|
1907
1961
|
json.dump({"target": target, "started_at": time.time()}, f)
|
|
1908
1962
|
except Exception:
|
|
1909
1963
|
pass
|
|
1910
|
-
|
|
1964
|
+
if has_ext:
|
|
1965
|
+
self._relaunch_claude(user_data_dirs)
|
|
1966
|
+
if has_app:
|
|
1967
|
+
self._restart_chatgpt_if_running()
|
|
1911
1968
|
self._update_available = False
|
|
1912
1969
|
self._sig = None
|
|
1970
|
+
host_word = "Claude" if has_ext else "ChatGPT"
|
|
1913
1971
|
if target:
|
|
1914
1972
|
# Honest phrasing: the verdict (success OR the real blocker)
|
|
1915
1973
|
# comes from _check_update_verdict once the new server settles.
|
|
1916
|
-
self._notify("S4L update", f"v{target} installed;
|
|
1974
|
+
self._notify("S4L update", f"v{target} installed; {host_word} is restarting. I'll confirm once it's live.")
|
|
1917
1975
|
else:
|
|
1918
|
-
self._notify("S4L updated", "
|
|
1976
|
+
self._notify("S4L updated", f"{host_word} restarted on the latest version.")
|
|
1919
1977
|
except Exception as e:
|
|
1920
1978
|
self._updating_mcpb = False
|
|
1921
1979
|
self._sig = None
|
|
@@ -4267,7 +4325,7 @@ class S4LMenuBar(rumps.App):
|
|
|
4267
4325
|
items.append(self._label(f"⬆ Update available · v{self._latest_version}"))
|
|
4268
4326
|
items.append(
|
|
4269
4327
|
rumps.MenuItem(
|
|
4270
|
-
"Update now & restart
|
|
4328
|
+
f"Update now & restart {self._update_host_word()}",
|
|
4271
4329
|
callback=self._do_mcpb_update,
|
|
4272
4330
|
)
|
|
4273
4331
|
)
|
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.9-rc.1",
|
|
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
package/scripts/claude_job.py
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
claude_job.py —
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
claude_job.py — THE DRAFT-ENGINE SEAM: every pure text->JSON drafting turn in
|
|
4
|
+
the pipeline lands here, and <state_dir>/draft-provider.json decides which
|
|
5
|
+
engine answers it (see scripts/draft_provider.py; design + verified facts in
|
|
6
|
+
docs/codex-port-design.md):
|
|
7
|
+
|
|
8
|
+
claude-desktop-queue (default) enqueue to a file queue drained by the
|
|
9
|
+
Claude Desktop s4l-worker scheduled task — the
|
|
10
|
+
original design, and the only option when Claude
|
|
11
|
+
Desktop is the box's sole authenticated engine.
|
|
12
|
+
claude-p answer inline via the real `claude -p` (_run_claude_p;
|
|
13
|
+
schema INLINED — the CLI parses --json-schema as JSON,
|
|
14
|
+
a file path breaks it).
|
|
15
|
+
codex-exec answer inline via the ChatGPT-app-bundled `codex exec`
|
|
16
|
+
(_run_codex_exec; gpt-5.6-sol default, schema rides
|
|
17
|
+
the prompt — OpenAI strict schema mode rejects our
|
|
18
|
+
lenient schemas).
|
|
19
|
+
|
|
20
|
+
All three return byte-identical claude `--output-format json` envelopes and
|
|
21
|
+
the same 0/1/79 exit semantics, so callers can't tell them apart. The queue
|
|
22
|
+
machinery below (next/result, worker notes, heartbeats) only runs for the
|
|
23
|
+
default provider; the inline providers skip it entirely, which also means no
|
|
24
|
+
warm-session leaks and no app-open requirement on those paths.
|
|
6
25
|
|
|
7
26
|
The deterministic pipeline never calls `claude` directly; every invocation goes
|
|
8
27
|
through scripts/run_claude.sh. For script tags mapped in TAG_TO_TYPE below,
|
|
@@ -461,19 +480,22 @@ def _bump_drain_timeout() -> None:
|
|
|
461
480
|
|
|
462
481
|
|
|
463
482
|
# --------------------------------------------------------------------------- #
|
|
464
|
-
#
|
|
483
|
+
# Worker self-reap (2026-06-27; DEFAULT ON since 2026-07-04 / v1.6.202) #
|
|
465
484
|
# --------------------------------------------------------------------------- #
|
|
466
485
|
# A scheduled-task worker turn finishes its one queue iteration but Claude
|
|
467
486
|
# Desktop keeps the agent-mode `claude` process warm (`--input-format
|
|
468
487
|
# stream-json`), so finished workers pile up and leak RAM. The launchd reaper
|
|
469
|
-
# (reap_stale_claude_sessions.py) is the GUARANTEE that bounds this. This
|
|
488
|
+
# (reap_stale_claude_sessions.py) is the GUARANTEE that bounds this. This
|
|
470
489
|
# path is a faster, source-side trim: once THIS worker is provably done (no work
|
|
471
490
|
# to do, or its result is already on disk), terminate OUR OWN session so it never
|
|
472
|
-
# becomes part of the standing pool.
|
|
473
|
-
# set,
|
|
491
|
+
# becomes part of the standing pool. It shipped opt-in (dormant unless
|
|
492
|
+
# S4L_WORKER_SELF_REAP was set), which meant it ran nowhere; when Desktop
|
|
493
|
+
# 1.18286.0 changed the cmdline shape the signature-fragile reaper missed too
|
|
494
|
+
# and boxes leaked (Karol, 53 piled workers). Since 2026-07-04 it is ON by
|
|
495
|
+
# default; opt OUT with S4L_WORKER_SELF_REAP=0 (see _self_reap_enabled).
|
|
474
496
|
#
|
|
475
497
|
# Safety properties:
|
|
476
|
-
# *
|
|
498
|
+
# * Respects the env kill switch (S4L_WORKER_SELF_REAP=0 disables it).
|
|
477
499
|
# * Only ever targets a process in OUR OWN ancestry that matches the reaper's
|
|
478
500
|
# worker signature (claude-code agent-mode session). The producer side
|
|
479
501
|
# (run-twitter-cycle.sh -> python) has no such ancestor, so a misplaced call
|
|
@@ -875,6 +897,50 @@ def _run_codex_exec(ns, qtype: str, prompt: str, schema_text: str | None) -> int
|
|
|
875
897
|
_disarm_deathwatch(job_id)
|
|
876
898
|
|
|
877
899
|
|
|
900
|
+
def _run_claude_p(ns, qtype: str, prompt: str, schema_text: str | None) -> int:
|
|
901
|
+
"""claude-p provider: answer the turn inline via the real `claude -p`
|
|
902
|
+
(Claude Code CLI, subscription-authenticated). No queue, no worker, no
|
|
903
|
+
Desktop-app requirement. stdout passes through untouched: it IS the
|
|
904
|
+
claude json envelope the callers parse. Key transform vs the caller's
|
|
905
|
+
original argv: the schema goes INLINE (the CLI parses the --json-schema
|
|
906
|
+
value as JSON; a file path 400s), and the prompt rides stdin so huge
|
|
907
|
+
drafting prompts never hit ARG_MAX."""
|
|
908
|
+
batch = (os.environ.get("BATCH_ID") or os.environ.get("SA_CYCLE_ID") or "-").strip() or "-"
|
|
909
|
+
job_id = uuid.uuid4().hex
|
|
910
|
+
_arm_deathwatch(job_id, qtype, batch)
|
|
911
|
+
_act_write(qtype)
|
|
912
|
+
cmd = ["claude", "-p", "--output-format", "json"]
|
|
913
|
+
if schema_text:
|
|
914
|
+
cmd += ["--json-schema", schema_text]
|
|
915
|
+
budget = max(60, ns.timeout - 60)
|
|
916
|
+
_plog(f"claude-p start {qtype} job {job_id} batch={batch} timeout={budget}s")
|
|
917
|
+
started = time.time()
|
|
918
|
+
try:
|
|
919
|
+
proc = subprocess.run(cmd, input=prompt, capture_output=True, text=True, timeout=budget)
|
|
920
|
+
except subprocess.TimeoutExpired:
|
|
921
|
+
_act_clear()
|
|
922
|
+
_plog(f"claude-p timed out after {budget}s on job {job_id} ({qtype})")
|
|
923
|
+
_disarm_deathwatch(job_id)
|
|
924
|
+
return 79
|
|
925
|
+
except FileNotFoundError:
|
|
926
|
+
_act_clear()
|
|
927
|
+
_plog(f"claude-p provider active but no `claude` CLI on PATH; failing {qtype}")
|
|
928
|
+
_disarm_deathwatch(job_id)
|
|
929
|
+
return 1
|
|
930
|
+
sys.stdout.write(proc.stdout or "")
|
|
931
|
+
sys.stdout.flush()
|
|
932
|
+
if proc.returncode == 0 and '"subtype":"success"' in (proc.stdout or ""):
|
|
933
|
+
_mark_drain_success()
|
|
934
|
+
_stamp_heartbeat("claude-p", qtype)
|
|
935
|
+
_plog(f"claude-p done job {job_id} batch={batch} ({qtype}) in {int(time.time() - started)}s")
|
|
936
|
+
else:
|
|
937
|
+
_act_clear()
|
|
938
|
+
tail = (proc.stderr or "").strip()[-300:]
|
|
939
|
+
_plog(f"claude-p rc={proc.returncode} on job {job_id} ({qtype}); stderr tail: {tail}")
|
|
940
|
+
_disarm_deathwatch(job_id)
|
|
941
|
+
return proc.returncode
|
|
942
|
+
|
|
943
|
+
|
|
878
944
|
def cmd_provider(ns) -> int:
|
|
879
945
|
_apply_state_dir_override(ns)
|
|
880
946
|
qtype = TAG_TO_TYPE.get(ns.tag)
|
|
@@ -926,10 +992,13 @@ def cmd_provider(ns) -> int:
|
|
|
926
992
|
_plog(f"S4L paused; refusing to enqueue {qtype} job")
|
|
927
993
|
return 1
|
|
928
994
|
|
|
929
|
-
# Provider branch (2026-08-06). codex-exec
|
|
930
|
-
#
|
|
931
|
-
|
|
995
|
+
# Provider branch (2026-08-06). codex-exec and claude-p answer inline;
|
|
996
|
+
# anything else is the queue (the Claude Desktop worker default).
|
|
997
|
+
_prov = draft_provider.get()
|
|
998
|
+
if _prov == "codex-exec":
|
|
932
999
|
return _run_codex_exec(ns, qtype, prompt, schema_text)
|
|
1000
|
+
if _prov == "claude-p":
|
|
1001
|
+
return _run_claude_p(ns, qtype, prompt, schema_text)
|
|
933
1002
|
|
|
934
1003
|
job_id = uuid.uuid4().hex
|
|
935
1004
|
created = time.time()
|
|
@@ -1384,16 +1453,14 @@ def cmd_eligible(ns: argparse.Namespace) -> int:
|
|
|
1384
1453
|
"""Routing probe for run_claude.sh: exit 0 when the tag is queue-mapped,
|
|
1385
1454
|
1 otherwise. TAG_TO_TYPE is the single routing truth — no env var.
|
|
1386
1455
|
|
|
1387
|
-
Provider
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1456
|
+
Provider selection happens INSIDE cmd_provider (codex-exec and claude-p
|
|
1457
|
+
both run inline there); eligible stays a pure tag probe. A fall-through
|
|
1458
|
+
design for claude-p was tried and reverted same-day 2026-08-06: pipeline
|
|
1459
|
+
callers pass --json-schema as a FILE PATH, and the real `claude` CLI
|
|
1460
|
+
parses that value as inline JSON, so the fall-through broke on every
|
|
1461
|
+
schema'd tag. The inline runner rewrites the schema to inline form.
|
|
1391
1462
|
"""
|
|
1392
|
-
if ns.tag
|
|
1393
|
-
return 1
|
|
1394
|
-
if draft_provider.get() == "claude-p":
|
|
1395
|
-
return 1
|
|
1396
|
-
return 0
|
|
1463
|
+
return 0 if ns.tag in TAG_TO_TYPE else 1
|
|
1397
1464
|
|
|
1398
1465
|
|
|
1399
1466
|
def main() -> int:
|
package/scripts/install.sh
CHANGED
|
@@ -107,6 +107,41 @@ os.replace(tmp, p)
|
|
|
107
107
|
PY
|
|
108
108
|
log "draft provider: $PROVIDER"
|
|
109
109
|
|
|
110
|
+
# 5. Persistent server (codex-flavor boxes only). Claude Desktop keeps the MCP
|
|
111
|
+
# server process alive while the app runs, so the menu bar always has a live
|
|
112
|
+
# loopback to POST approvals to. The ChatGPT app spawns servers per chat and
|
|
113
|
+
# kills them after, which would strand menu-bar approvals between sessions.
|
|
114
|
+
# A KeepAlive LaunchAgent holds one server up; stdin is held open by tail
|
|
115
|
+
# because the stdio MCP transport exits on stdin EOF.
|
|
116
|
+
if [ "$PROVIDER" = "codex-exec" ]; then
|
|
117
|
+
PLIST="$HOME/Library/LaunchAgents/com.m13v.s4l-server.plist"
|
|
118
|
+
mkdir -p "$HOME/Library/LaunchAgents" "$STATE_DIR/logs"
|
|
119
|
+
cat > "$PLIST" <<EOF
|
|
120
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
121
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
122
|
+
<plist version="1.0"><dict>
|
|
123
|
+
<key>Label</key><string>com.m13v.s4l-server</string>
|
|
124
|
+
<key>ProgramArguments</key><array>
|
|
125
|
+
<string>/bin/bash</string><string>-c</string>
|
|
126
|
+
<string>tail -f /dev/null | exec "$NODE" "$SERVER"</string>
|
|
127
|
+
</array>
|
|
128
|
+
<key>EnvironmentVariables</key><dict>
|
|
129
|
+
<key>S4L_HOST</key><string>codex</string>
|
|
130
|
+
<key>S4L_STATE_DIR</key><string>$STATE_DIR</string>
|
|
131
|
+
<key>PATH</key><string>$SAFE_PATH</string>
|
|
132
|
+
</dict>
|
|
133
|
+
<key>RunAtLoad</key><true/>
|
|
134
|
+
<key>KeepAlive</key><true/>
|
|
135
|
+
<key>StandardOutPath</key><string>$STATE_DIR/logs/s4l-server.log</string>
|
|
136
|
+
<key>StandardErrorPath</key><string>$STATE_DIR/logs/s4l-server.err.log</string>
|
|
137
|
+
</dict></plist>
|
|
138
|
+
EOF
|
|
139
|
+
launchctl bootout "gui/$(id -u)/com.m13v.s4l-server" 2>/dev/null || true
|
|
140
|
+
launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null \
|
|
141
|
+
&& log "persistent server LaunchAgent loaded (com.m13v.s4l-server)" \
|
|
142
|
+
|| log "WARN: could not load com.m13v.s4l-server; menu-bar approvals need the ChatGPT app open"
|
|
143
|
+
fi
|
|
144
|
+
|
|
110
145
|
log "done."
|
|
111
146
|
echo
|
|
112
147
|
echo "Next steps:"
|