@m13v/s4l 1.6.204-rc.21 → 1.6.204-rc.23
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 +28 -10
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +32 -14
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +47 -5
- package/scripts/reap_stale_claude_sessions.py +20 -25
package/mcp/dist/index.js
CHANGED
|
@@ -2706,8 +2706,19 @@ async function autopilotLoaded() {
|
|
|
2706
2706
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
2707
2707
|
// Claude turn, writes the result back, and stops.
|
|
2708
2708
|
// ===========================================================================
|
|
2709
|
-
const QUEUE_WORKER_PROMPT_VERSION =
|
|
2709
|
+
const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
|
|
2710
2710
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
2711
|
+
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
2712
|
+
// 240s (4 min): comfortably inside the 900s single-Bash-call survival verified
|
|
2713
|
+
// live on 2026-07-06, and covers a meaningful chunk of the ~8min average
|
|
2714
|
+
// real job inter-arrival gap measured on the box, while still keeping each
|
|
2715
|
+
// worker session bounded. The cron's `* * * * *` cadence remains the outer
|
|
2716
|
+
// safety net for whatever the poll window doesn't catch.
|
|
2717
|
+
// COUPLING: scripts/reap_stale_claude_sessions.py's S4L_REAPER_CLAIM_GRACE_SEC
|
|
2718
|
+
// default MUST stay >= this value + margin — a claimless session inside this
|
|
2719
|
+
// poll window is legitimately still working, not a husk, and a too-tight
|
|
2720
|
+
// claim_grace would SIGTERM it mid-poll before it ever gets to claim.
|
|
2721
|
+
const QUEUE_WORKER_POLL_SECONDS = 240;
|
|
2711
2722
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
2712
2723
|
const QUEUE_WORKERS = [
|
|
2713
2724
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -2831,17 +2842,24 @@ function queueWorkerBody(spec) {
|
|
|
2831
2842
|
`other tool, or trying to "investigate", STALLS it forever.`,
|
|
2832
2843
|
``,
|
|
2833
2844
|
`PACING — CRITICAL: this unattended session is terminated ~90 seconds after ` +
|
|
2834
|
-
`your LAST tool call (a host inactivity timeout).
|
|
2835
|
-
`
|
|
2836
|
-
`
|
|
2837
|
-
`
|
|
2838
|
-
`
|
|
2845
|
+
`your LAST tool call (a host inactivity timeout). That clock only runs BETWEEN ` +
|
|
2846
|
+
`tool calls, not during one — step 1 below is a single Bash call that can ` +
|
|
2847
|
+
`legitimately take several minutes to return, and that is fine. Make your ` +
|
|
2848
|
+
`first tool call promptly, and once you are drafting (step 2), if the job's ` +
|
|
2849
|
+
`prompt gives you per-item persist commands to run (its own quick Bash calls), ` +
|
|
2850
|
+
`run them as you complete each item instead of working silently — those calls ` +
|
|
2851
|
+
`are what keep the session alive. The prompt file may begin with a WORKER ` +
|
|
2852
|
+
`EXECUTION NOTES header; follow it exactly.`,
|
|
2839
2853
|
``,
|
|
2840
2854
|
`Steps:`,
|
|
2841
|
-
`1.
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
`
|
|
2855
|
+
`1. Look for the next job. Run this EXACT Bash command and let it run to ` +
|
|
2856
|
+
`completion — it polls internally for up to ${Math.round(QUEUE_WORKER_POLL_SECONDS / 60)} ` +
|
|
2857
|
+
`minutes before giving up, so it may take a while to return. That is normal: ` +
|
|
2858
|
+
`do NOT interrupt it and do NOT make any other tool call while it is running.`,
|
|
2859
|
+
` ${py} ${job} next --type any --prompt-file --wait-seconds ${QUEUE_WORKER_POLL_SECONDS} --state-dir ${sd}`,
|
|
2860
|
+
` It prints one line of JSON once it returns. If it prints "{}" (empty), no ` +
|
|
2861
|
+
`job showed up during the whole poll window — report "no jobs" in one line ` +
|
|
2862
|
+
`and STOP. You are done.`,
|
|
2845
2863
|
`2. Otherwise it prints {"job_id":"...","prompt_file":"...","schema_file":...}. ` +
|
|
2846
2864
|
`Use the Read tool to read prompt_file; it is the complete, self-contained ` +
|
|
2847
2865
|
`instruction the pipeline wrote for you. If the Read result says it is partial ` +
|
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.6.204-rc.
|
|
5
|
+
"version": "1.6.204-rc.23",
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -908,27 +908,45 @@ class _ReviewController(NSObject):
|
|
|
908
908
|
self._stats_popover = None
|
|
909
909
|
|
|
910
910
|
@objc.python_method
|
|
911
|
-
def _show_popover(self,
|
|
911
|
+
def _show_popover(self, content, anchor, what):
|
|
912
912
|
"""One popover surface for both eyes (thread stats, draft details).
|
|
913
913
|
Only one is ever open: the two anchors are far apart, so hover-out
|
|
914
|
-
closes the first before hover-in opens the other.
|
|
915
|
-
|
|
914
|
+
closes the first before hover-in opens the other. `content` is a
|
|
915
|
+
single string (stats: one compact line) or a list of strings
|
|
916
|
+
(details: one bulleted row per field, gapped vertically so the
|
|
917
|
+
fields read as a list instead of one dense run-on paragraph)."""
|
|
918
|
+
if anchor is None or not content:
|
|
916
919
|
return
|
|
917
920
|
if self._stats_popover is not None and self._stats_popover.isShown():
|
|
918
921
|
return
|
|
922
|
+
lines = content if isinstance(content, list) else [content]
|
|
923
|
+
bulleted = len(lines) > 1
|
|
924
|
+
rows = [f"• {line}" if bulleted else line for line in lines]
|
|
925
|
+
row_gap = 8 if bulleted else 0
|
|
919
926
|
font = NSFont.systemFontOfSize_(12)
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
)
|
|
923
|
-
# Wrap-aware measurement (the details popover is multi-line; option 1 =
|
|
924
|
-
# NSStringDrawingUsesLineFragmentOrigin). +34: 13px side insets plus
|
|
927
|
+
# Wrap-aware measurement per row (option 1 = NSStringDrawingUsesLine
|
|
928
|
+
# FragmentOrigin; a row can itself be multi-line, e.g. the truncated
|
|
929
|
+
# original-thread text). +34 on width: 13px side insets plus
|
|
925
930
|
# NSTextField's own ~4px internal padding, which otherwise clips the
|
|
926
|
-
# last word.
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
931
|
+
# last word. +3 on each row's height: buffer against descender
|
|
932
|
+
# clipping (matches the single-line sizing this replaces).
|
|
933
|
+
heights = []
|
|
934
|
+
pw = 0
|
|
935
|
+
for row in rows:
|
|
936
|
+
s = NSAttributedString.alloc().initWithString_attributes_(
|
|
937
|
+
row, {NSFontAttributeName: font}
|
|
938
|
+
)
|
|
939
|
+
measured = s.boundingRectWithSize_options_(NSMakeSize(300, 10_000), 1)
|
|
940
|
+
heights.append(int(measured.size.height) + 3)
|
|
941
|
+
pw = max(pw, int(measured.size.width))
|
|
942
|
+
pw += 34
|
|
943
|
+
ph = sum(heights) + row_gap * (len(rows) - 1) + 16
|
|
930
944
|
view = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, pw, ph))
|
|
931
|
-
|
|
945
|
+
y = ph - 8
|
|
946
|
+
for row, h in zip(rows, heights):
|
|
947
|
+
y -= h
|
|
948
|
+
view.addSubview_(_label(NSMakeRect(13, y, pw - 26, h), row, size=12))
|
|
949
|
+
y -= row_gap
|
|
932
950
|
vc = NSViewController.alloc().init()
|
|
933
951
|
vc.setView_(view)
|
|
934
952
|
pop = NSPopover.alloc().init()
|
|
@@ -963,7 +981,7 @@ class _ReviewController(NSObject):
|
|
|
963
981
|
@objc.python_method
|
|
964
982
|
def _show_details_popover(self):
|
|
965
983
|
lines = _details_lines(self._drafts[self._idx])
|
|
966
|
-
self._show_popover(
|
|
984
|
+
self._show_popover(lines, self._details_btn, "details")
|
|
967
985
|
|
|
968
986
|
# Click on an eye SHOWS its popover, never toggles it closed: a click is
|
|
969
987
|
# physically preceded by hover (mouseEntered already opened it), so a
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.6.204-rc.
|
|
3
|
+
"version": "1.6.204-rc.23",
|
|
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.6.204-rc.
|
|
3
|
+
"version": "1.6.204-rc.23",
|
|
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",
|
package/scripts/claude_job.py
CHANGED
|
@@ -200,6 +200,13 @@ VALUE_FLAGS = {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
POLL_INTERVAL_S = 2.0
|
|
203
|
+
# Consumer-side poll cadence for `next --wait-seconds` (see cmd_next). Separate
|
|
204
|
+
# constant from POLL_INTERVAL_S (the producer's own wait loop) because the two
|
|
205
|
+
# sides have different cost profiles: the producer polls a single result file
|
|
206
|
+
# it's blocked on anyway, while the worker's poll re-execs claude_job.py itself
|
|
207
|
+
# each pass, so a slightly coarser cadence avoids needless process churn during
|
|
208
|
+
# a multi-minute wait.
|
|
209
|
+
WORKER_POLL_INTERVAL_S = 5.0
|
|
203
210
|
# Per-call budget the producer waits for ONE claude job (a query or a draft-prep
|
|
204
211
|
# reasoning turn). Was 600s, which sat right at the edge of the draft call's real
|
|
205
212
|
# ~9-10 min need: on the QA box ~41% of twitter-prep jobs breached 600s and got
|
|
@@ -804,9 +811,11 @@ def _agent_session_pid():
|
|
|
804
811
|
return None
|
|
805
812
|
|
|
806
813
|
|
|
807
|
-
def
|
|
808
|
-
|
|
809
|
-
|
|
814
|
+
def _attempt_claim(ns, qtype: str) -> bool:
|
|
815
|
+
"""One pass over the pending dirs: try to claim the oldest job. Returns True
|
|
816
|
+
(and prints the claimed job's payload) iff a job was claimed; False if the
|
|
817
|
+
queue was empty this pass. Split out of cmd_next so the poll loop below can
|
|
818
|
+
call it repeatedly without duplicating the claim/stamp/print logic."""
|
|
810
819
|
# "any" (the universal type-blind worker) scans EVERY pending type dir;
|
|
811
820
|
# a comma list scans those types; a single type keeps legacy behavior.
|
|
812
821
|
# Job filenames start with a zero-padded nanosecond timestamp, so one
|
|
@@ -896,8 +905,34 @@ def cmd_next(ns) -> int:
|
|
|
896
905
|
payload["prompt"] = job["prompt"]
|
|
897
906
|
payload["schema"] = job.get("schema")
|
|
898
907
|
print(json.dumps(payload))
|
|
899
|
-
return
|
|
900
|
-
|
|
908
|
+
return True
|
|
909
|
+
return False
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def cmd_next(ns) -> int:
|
|
913
|
+
_apply_state_dir_override(ns)
|
|
914
|
+
qtype = ns.type
|
|
915
|
+
wait_seconds = max(0, ns.wait_seconds or 0)
|
|
916
|
+
# wait_seconds=0 (default) is the legacy single-shot behavior: one pass,
|
|
917
|
+
# then done. wait_seconds>0 polls in a bounded loop within THIS ONE process
|
|
918
|
+
# (one Bash call from the calling session's perspective) instead of relying
|
|
919
|
+
# on the scheduled task's own cron cadence to re-check. A finished-but-empty
|
|
920
|
+
# pass sleeps WORKER_POLL_INTERVAL_S and tries again until the deadline.
|
|
921
|
+
#
|
|
922
|
+
# COUPLING: the reaper's claim_grace (S4L_REAPER_CLAIM_GRACE_SEC in
|
|
923
|
+
# reap_stale_claude_sessions.py) must stay >= whatever --wait-seconds the
|
|
924
|
+
# worker prompt actually passes, plus margin — a claimless session polling
|
|
925
|
+
# inside this loop is legitimate, not a husk, and a too-tight claim_grace
|
|
926
|
+
# would SIGTERM it mid-poll before it ever gets a chance to claim.
|
|
927
|
+
deadline = time.time() + wait_seconds
|
|
928
|
+
while True:
|
|
929
|
+
if _attempt_claim(ns, qtype):
|
|
930
|
+
return 0
|
|
931
|
+
remaining = deadline - time.time()
|
|
932
|
+
if remaining <= 0:
|
|
933
|
+
break
|
|
934
|
+
time.sleep(min(WORKER_POLL_INTERVAL_S, remaining))
|
|
935
|
+
print(json.dumps({})) # no work found within the wait window
|
|
901
936
|
_maybe_self_reap() # idle turn, no job claimed — safe to retire this session
|
|
902
937
|
return 0
|
|
903
938
|
|
|
@@ -1040,6 +1075,13 @@ def main() -> int:
|
|
|
1040
1075
|
action="store_true",
|
|
1041
1076
|
help="write the prompt/schema to sidecar files and print their paths",
|
|
1042
1077
|
)
|
|
1078
|
+
pn.add_argument(
|
|
1079
|
+
"--wait-seconds",
|
|
1080
|
+
type=int,
|
|
1081
|
+
default=0,
|
|
1082
|
+
help="poll for a job up to this many seconds before giving up "
|
|
1083
|
+
"(0 = legacy single-shot: check once and return immediately)",
|
|
1084
|
+
)
|
|
1043
1085
|
pn.set_defaults(func=cmd_next)
|
|
1044
1086
|
|
|
1045
1087
|
pr = sub.add_parser("result", help="store a job's result")
|
|
@@ -804,33 +804,28 @@ def main() -> int:
|
|
|
804
804
|
# never at risk.
|
|
805
805
|
max_group = _env_int("S4L_REAPER_MAX_GROUP", 2)
|
|
806
806
|
|
|
807
|
-
# (3) Claim grace — the PRIMARY brake (2026-07-01, per Matthew
|
|
808
|
-
# the
|
|
809
|
-
#
|
|
810
|
-
#
|
|
811
|
-
#
|
|
812
|
-
#
|
|
813
|
-
#
|
|
807
|
+
# (3) Claim grace — the PRIMARY brake (2026-07-01, per Matthew; updated 2026-07-06
|
|
808
|
+
# for the polling worker). A worker no longer checks the queue exactly once per
|
|
809
|
+
# fire: claude_job.py::cmd_next now takes --wait-seconds and polls internally for
|
|
810
|
+
# up to QUEUE_WORKER_POLL_SECONDS (mcp/src/index.ts, 240s default) before giving
|
|
811
|
+
# up. So a claimless session may be legitimately, actively polling for the WHOLE
|
|
812
|
+
# poll window — not yet a husk — and only becomes a proven husk once it exits
|
|
813
|
+
# (prints "{}") without ever stamping a claim_pid. Reaping it mid-poll would kill
|
|
814
|
+
# a session that might claim a job seconds later.
|
|
814
815
|
#
|
|
815
|
-
#
|
|
816
|
-
#
|
|
817
|
-
#
|
|
818
|
-
#
|
|
819
|
-
#
|
|
820
|
-
#
|
|
821
|
-
# type-driven rule: spare drafters + spare boot-window newborns, reap all the rest.
|
|
822
|
-
# Worst case of an over-tight grace is a job delayed one tick (it stays in pending
|
|
823
|
-
# for the next worker), never a lost draft. A DRAFTING session is protected by
|
|
824
|
-
# claim_pids, not by grace, so no grace value can kill a real draft (this is what
|
|
825
|
-
# makes the old "~120s code-143 mid-draft kill" impossible now).
|
|
816
|
+
# claim_grace must therefore bound "boot + one full poll window", not just
|
|
817
|
+
# "boot + one cmd_next call" as before. It stays the type-driven rule: spare
|
|
818
|
+
# drafters (claim_pids) + spare in-grace newborns, reap everything else. Worst
|
|
819
|
+
# case of an over-tight grace is a job delayed until the next cron tick's worker
|
|
820
|
+
# (it stays in pending), never a lost draft — a DRAFTING session is protected by
|
|
821
|
+
# claim_pids, not by grace, so no grace value can kill a real draft.
|
|
826
822
|
#
|
|
827
|
-
# Default
|
|
828
|
-
#
|
|
829
|
-
#
|
|
830
|
-
#
|
|
831
|
-
#
|
|
832
|
-
|
|
833
|
-
claim_grace = _env_int("S4L_REAPER_CLAIM_GRACE_SEC", 60)
|
|
823
|
+
# Default 300s (2026-07-06): QUEUE_WORKER_POLL_SECONDS (240s) + ~60s margin for
|
|
824
|
+
# cold agent-mode boot (skill load + MCP init) and this reaper's own ps-scan
|
|
825
|
+
# latency. COUPLING: must stay >= QUEUE_WORKER_POLL_SECONDS + margin — if that
|
|
826
|
+
# constant grows, bump this default (or S4L_REAPER_CLAIM_GRACE_SEC) to match, or
|
|
827
|
+
# the reaper will SIGTERM a legitimately-polling worker before it ever claims.
|
|
828
|
+
claim_grace = _env_int("S4L_REAPER_CLAIM_GRACE_SEC", 300)
|
|
834
829
|
|
|
835
830
|
inflight = count_running_jobs() # None => queue unreadable => age-gate fallback
|
|
836
831
|
claim_pids = running_claim_pids() # agent-session pids actively holding a claim
|