@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 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 = 7; // 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.
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). Make your first tool call ` +
2835
- `promptly, and if the job's prompt gives you per-item persist commands to run ` +
2836
- `(its own quick Bash calls), run them as you complete each item instead of ` +
2837
- `working silently — those calls are what keep the session alive. The prompt ` +
2838
- `file may begin with a WORKER EXECUTION NOTES header; follow it exactly.`,
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. Claim the next job. Run this EXACT Bash command:`,
2842
- ` ${py} ${job} next --type any --prompt-file --state-dir ${sd}`,
2843
- ` It prints one line of JSON. If it prints "{}" (empty), there is NO work — ` +
2844
- `report "no jobs" in one line and STOP. You are done.`,
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 ` +
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.204-rc.21",
3
- "installedAt": "2026-07-07T03:35:11.473Z"
2
+ "version": "1.6.204-rc.23",
3
+ "installedAt": "2026-07-07T03:58:28.853Z"
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.6.204-rc.21",
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": {
@@ -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, text, anchor, what):
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
- if anchor is None or not text:
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
- s = NSAttributedString.alloc().initWithString_attributes_(
921
- text, {NSFontAttributeName: font}
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
- measured = s.boundingRectWithSize_options_(NSMakeSize(300, 10_000), 1)
928
- pw = int(measured.size.width) + 34
929
- ph = int(measured.size.height) + 19
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
- view.addSubview_(_label(NSMakeRect(13, 8, pw - 26, ph - 16), text, size=12))
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("\n".join(lines), self._details_btn, "details")
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.21",
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.21",
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",
@@ -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 cmd_next(ns) -> int:
808
- _apply_state_dir_override(ns)
809
- qtype = ns.type
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 0
900
- print(json.dumps({})) # no work
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). A worker checks
808
- # the queue EXACTLY ONCE per fire: claude_job.py::cmd_next is single-shot — it
809
- # claims one pending job (stamping claim_pid) or prints {} and returns; it never
810
- # polls again. So within one cron tick of spawning, a session either CLAIMS a job
811
- # (=> it has a "type", is actively drafting, and is spared outright via
812
- # running_claim_pids()) or finds the queue empty and becomes a PERMANENT typeless
813
- # husk that will NEVER claim again. Those husks are exactly what we want to kill.
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
- # The ONLY reason to spare a claimless session is that it may not have run its one
816
- # cmd_next yet (cold agent-mode boot: skill load + MCP init before the first tool
817
- # call). claim_grace bounds that boot+claim window. Measured on the box:
818
- # enqueue->claim was ALWAYS < 60s (3-55s across 85 claims); 120s is a generous
819
- # margin. Past claim_grace a claimless session is a proven husk -> reap it now,
820
- # regardless of the 35-min age ceiling and regardless of group size. This is the
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 60s (2026-07-01, per Matthew): the boot+claim window is comfortably
828
- # inside one cron tick — measured enqueue->claim was always < 60s (3-55s across 85
829
- # claims) and that figure ALREADY includes the claiming worker's spawn+boot+cmd_next.
830
- # 60s tightens the steady-state floor to ~2-3 warm sessions (one tick of newborns +
831
- # any active drafter) instead of ~4, while still never racing a real claim. Bump it
832
- # back up via S4L_REAPER_CLAIM_GRACE_SEC if cold boots ever start exceeding a tick.
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