@m13v/s4l 1.7.1 → 1.7.2-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 CHANGED
@@ -2837,6 +2837,16 @@ async function autopilotLoaded() {
2837
2837
  // Claude turn, writes the result back, and stops.
2838
2838
  // ===========================================================================
2839
2839
  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.
2840
+ // v9 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
2841
+ // sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
2842
+ // within one continuous worker session) instead of drafting inline. Validated
2843
+ // via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
2844
+ // delegated work, survives); the one hard constraint proven: the delegated
2845
+ // sub-agent must never fully idle-wait (e.g. background + wait on a Monitor
2846
+ // notification) or the host kills the whole parent+child chain in 1-3 min.
2847
+ // Never live-fire tested against a real production job. Full design, what's
2848
+ // validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
2849
+ // Bump this constant to 9 only once that plan is actually implemented.
2840
2850
  const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
2841
2851
  // How long ONE `next --wait-seconds` call polls before giving up and exiting.
2842
2852
  // 240s (4 min): comfortably inside the 900s single-Bash-call survival verified
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.1",
3
- "installedAt": "2026-07-09T00:28:03.233Z"
2
+ "version": "1.7.2-rc.1",
3
+ "installedAt": "2026-07-09T01:04:07.949Z"
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.1",
5
+ "version": "1.7.2-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": {
@@ -418,7 +418,21 @@ def _details_lines(d):
418
418
  lines.append(
419
419
  f"Original thread ({lang.lower()}): {_truncate(d.get('thread_text'), 280)}"
420
420
  )
421
- if (d.get("reply_text_en") or "").strip():
421
+ # Two-draft cards: reply_text_en only ever mirrors Draft A (the
422
+ # canonical single-draft field), so Draft B's translation would be
423
+ # silently dropped from the popover unless we read each slot's own
424
+ # text_en directly off the drafts array.
425
+ dual_drafts = d.get("drafts")
426
+ if isinstance(dual_drafts, list) and len(dual_drafts) == 2:
427
+ slot_labels = {"a": "Draft A", "b": "Draft B"}
428
+ for draft in dual_drafts:
429
+ text_en = (draft.get("text_en") or "").strip()
430
+ if text_en:
431
+ label = slot_labels.get(
432
+ (draft.get("variant") or "").strip().lower(), "Draft"
433
+ )
434
+ lines.append(f"{label} in English: {_truncate(text_en, 280)}")
435
+ elif (d.get("reply_text_en") or "").strip():
422
436
  lines.append(
423
437
  f"Draft in English: {_truncate(d.get('reply_text_en'), 280)}"
424
438
  )
@@ -775,6 +775,74 @@ class S4LMenuBar(rumps.App):
775
775
  "that schedules the draft tasks for this account",
776
776
  )
777
777
 
778
+ def _finish_schedule_setup(self, _=None):
779
+ """One-click fix for schedule_state == 'missing' when
780
+ scheduled_task_selfheal.can_create_for_active_account() confirms a
781
+ session directory already exists for the active account (2026-07-08):
782
+ quit Claude, create the registration via a direct file write (the
783
+ same heal() the update flow uses — see its module docstring for why
784
+ this is equivalent to what create_scheduled_task would produce), then
785
+ relaunch. Primary action for this case now instead of re-arm: no
786
+ clipboard paste, no chat turn required. Re-arm remains the fallback
787
+ for the rarer case where no session directory exists yet for the
788
+ active account (see _build_menu) — fix 5 never fabricates one."""
789
+ _activate_front()
790
+ choice = _show_alert(
791
+ title="Finish setting up drafts?",
792
+ message=(
793
+ "Claude needs to restart once to finish registering your draft "
794
+ "schedule for this account. Its window will close and reopen "
795
+ "in a moment — drafting starts within a few minutes after."
796
+ ),
797
+ ok="Restart & Finish Setup", cancel="Cancel",
798
+ )
799
+ if choice != 1:
800
+ return
801
+ _capture_msg(
802
+ "S4L finish-schedule-setup clicked",
803
+ phase="draft_schedule",
804
+ reason="missing",
805
+ _extra={"scheduled_tasks": _registry_summary_for_capture()},
806
+ )
807
+ self._notify("S4L", "Restarting Claude Desktop… setting up your draft schedule.")
808
+ threading.Thread(target=self._finish_schedule_setup_work, daemon=True).start()
809
+
810
+ def _finish_schedule_setup_work(self):
811
+ try:
812
+ user_data_dirs = self._claude_user_data_dirs()
813
+ self._quit_claude_and_wait()
814
+ # Claude is down: the same safe window _rewrite_scheduled_task_cwd
815
+ # always required. In-process is fine here (unlike the update
816
+ # flow's _fresh variant) — no new bundle was just downloaded, so
817
+ # the currently-running code IS the current code; nothing to go
818
+ # stale against.
819
+ self._rewrite_scheduled_task_cwd()
820
+ self._relaunch_claude(user_data_dirs)
821
+ self._sig = None
822
+ # Verify rather than claim success unconditionally — the earlier,
823
+ # now-deleted silent restart-fix drew a direct complaint for
824
+ # giving zero feedback either way (2026-07-08). CREATED_GRACE in
825
+ # schedule_state.py means a freshly-created, never-yet-fired task
826
+ # already reads "ok", so this doesn't need to wait for an actual
827
+ # first fire — just for Claude to be back up and the file to have
828
+ # landed.
829
+ time.sleep(12)
830
+ state = self._schedule_state()
831
+ if state == "ok":
832
+ self._notify(
833
+ "S4L drafts set up",
834
+ "Your draft schedule is registered — drafting starts within a few minutes.",
835
+ )
836
+ else:
837
+ self._notify(
838
+ "S4L couldn't finish setup",
839
+ "Restarting didn't register the schedule. Open the S4L menu → "
840
+ "“Set up draft schedule” to finish it manually.",
841
+ )
842
+ except Exception as e:
843
+ self._notify("S4L setup restart failed", str(e)[:140])
844
+ _capture(e, phase="finish_schedule_setup")
845
+
778
846
  # ---- schedule-state detection ----------------------------------------
779
847
  def _schedule_state(self):
780
848
  """Is the draft schedule registered AND running for the live account?
@@ -2400,11 +2468,24 @@ class S4LMenuBar(rumps.App):
2400
2468
  "schedule” to re-register it.",
2401
2469
  )
2402
2470
  else:
2403
- self._notify(
2404
- "S4L draft autopilot not scheduled",
2405
- "No draft tasks are running on this Claude account (switching "
2406
- "accounts clears them). Open the S4L menu → “Set up draft schedule”.",
2407
- )
2471
+ can_selfheal = False
2472
+ try:
2473
+ import scheduled_task_selfheal
2474
+ can_selfheal = scheduled_task_selfheal.can_create_for_active_account()
2475
+ except Exception:
2476
+ pass
2477
+ if can_selfheal:
2478
+ self._notify(
2479
+ "S4L draft autopilot not scheduled",
2480
+ "No draft tasks are running on this Claude account (switching "
2481
+ "accounts clears them). Open the S4L menu → “Finish setting up drafts”.",
2482
+ )
2483
+ else:
2484
+ self._notify(
2485
+ "S4L draft autopilot not scheduled",
2486
+ "No draft tasks are running on this Claude account (switching "
2487
+ "accounts clears them). Open the S4L menu → “Set up draft schedule”.",
2488
+ )
2408
2489
  self._stall_notified = True
2409
2490
  elif not attention and self._stall_notified:
2410
2491
  if self._attention_clear_since is None:
@@ -2988,7 +3069,23 @@ class S4LMenuBar(rumps.App):
2988
3069
  items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2989
3070
  else:
2990
3071
  items.append(self._label("⚠ Draft tasks aren’t scheduled on this account"))
2991
- items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
3072
+ # Prefer the automatic fix (2026-07-08): if the active account
3073
+ # already has a session directory, _finish_schedule_setup can
3074
+ # create the registration directly (heal() fix 5) with no
3075
+ # clipboard paste needed. Fall back to re-arm only when fix 5
3076
+ # has nowhere to write (no session dir yet for this account —
3077
+ # it never fabricates one), since that's the one case where
3078
+ # only the live create_scheduled_task host tool can help.
3079
+ can_selfheal = False
3080
+ try:
3081
+ import scheduled_task_selfheal
3082
+ can_selfheal = scheduled_task_selfheal.can_create_for_active_account()
3083
+ except Exception:
3084
+ pass
3085
+ if can_selfheal:
3086
+ items.append(rumps.MenuItem("Finish setting up drafts", callback=self._finish_schedule_setup))
3087
+ else:
3088
+ items.append(rumps.MenuItem("Set up draft schedule for this account", callback=self._rearm))
2992
3089
  items.append(rumps.separator)
2993
3090
 
2994
3091
  if not runtime_ready:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.1",
3
+ "version": "1.7.2-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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.1",
3
+ "version": "1.7.2-rc.1",
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",
@@ -335,6 +335,32 @@ def heartbeat_path() -> str:
335
335
  return os.path.join(queue_root(), "worker-heartbeat.json")
336
336
 
337
337
 
338
+ def _arm_deathwatch(job_id: str, qtype: str, batch: str) -> None:
339
+ """Best-effort dead-man's-switch (2026-07-08): arm scripts/producer_deathwatch.py
340
+ to flag an UNEXPECTED death (SIGKILL/OOM/hard crash) of THIS process while
341
+ it's blocked in the cmd_provider() poll loop below — the exact gap that
342
+ made orphaned salvage results ("worker drafted, no card") unexplainable.
343
+ Every normal return path in cmd_provider() calls _disarm_deathwatch()
344
+ first, so a clean exit never produces a report. Shared with
345
+ run_claude.sh's direct-exec path (every non-queue platform), which calls
346
+ producer_deathwatch.py's `arm`/`disarm` CLI directly instead of through
347
+ this Python wrapper — see that file for the single implementation both
348
+ callers share."""
349
+ try:
350
+ import producer_deathwatch as pdw
351
+ pdw.arm(os.getpid(), job_id, qtype, batch, call_path="queue")
352
+ except Exception:
353
+ pass
354
+
355
+
356
+ def _disarm_deathwatch(job_id: str) -> None:
357
+ try:
358
+ import producer_deathwatch as pdw
359
+ pdw.disarm(job_id)
360
+ except Exception:
361
+ pass
362
+
363
+
338
364
  def _stamp_heartbeat(event: str, qtype: str | None = None) -> None:
339
365
  """Best-effort: never let a heartbeat write failure break the queue."""
340
366
  try:
@@ -690,6 +716,7 @@ def cmd_provider(ns) -> int:
690
716
  running_path = os.path.join(running_dir(), fname)
691
717
  _atomic_write(pending_path, job)
692
718
  _plog(f"enqueued {qtype} job {job_id} batch={batch}; waiting for a scheduled task (timeout {ns.timeout}s)")
719
+ _arm_deathwatch(job_id, qtype, batch)
693
720
  # Narrate the (multi-minute) block to the menu bar. The launchd draft lane has
694
721
  # no other activity writer, so without this the box looks idle while it works.
695
722
  # Cleared by run-draft-and-publish.sh's exit trap at cycle end (and by the
@@ -732,6 +759,7 @@ def cmd_provider(ns) -> int:
732
759
  os.remove(res_path)
733
760
  if res.get("status") == "error":
734
761
  _plog(f"job {job_id} returned error: {res.get('error', 'unknown')}")
762
+ _disarm_deathwatch(job_id)
735
763
  return 1
736
764
  obj = res.get("result")
737
765
  # Emit a claude `--output-format json` shaped envelope so the
@@ -759,6 +787,7 @@ def cmd_provider(ns) -> int:
759
787
  except Exception:
760
788
  _ncand = "?"
761
789
  _plog(f"consumed result for job {job_id} batch={batch} ({qtype}); {_ncand} candidates -> producer assembles the plan")
790
+ _disarm_deathwatch(job_id)
762
791
  return 0
763
792
  time.sleep(POLL_INTERVAL_S)
764
793
 
@@ -779,6 +808,7 @@ def cmd_provider(ns) -> int:
779
808
  # flicker the ⚠ off). Cleared only when a draft actually drains.
780
809
  _bump_drain_timeout()
781
810
  _plog(f"timed out after {ns.timeout}s waiting for job {job_id} batch={batch} ({qtype}); removed the job")
811
+ _disarm_deathwatch(job_id)
782
812
  return 79 # mirror run_claude.sh's "blocked, skip cleanly" exit code
783
813
 
784
814
 
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """producer_deathwatch.py — dead-man's-switch for every Claude-calling
3
+ producer process in the pipeline.
4
+
5
+ Watches a single PID: either scripts/claude_job.py's blocking provider wait
6
+ (cmd_provider, the queue path used by twitter-prep/feedback-digest/etc.), or
7
+ scripts/run_claude.sh's direct `claude -p` exec (every other platform:
8
+ reddit, linkedin, github, moltbook, instagram, dm-outreach-*, ...). If that
9
+ PID disappears while its arm marker still exists, something killed it
10
+ (SIGKILL / OOM / hard crash) rather than a normal return — a clean return
11
+ always disarms first. This is the exact gap that made orphaned salvage
12
+ results ("worker drafted, no card") unexplainable: the dying process can
13
+ never log its own death, and salvage only sees the aftermath up to
14
+ --max-age-hours later.
15
+
16
+ On an unexpected death this:
17
+ 1. Snapshots memory pressure + related processes.
18
+ 2. Appends a structured JSON line to producer-deathwatch.jsonl (local,
19
+ box-only, for offline/box-local debugging).
20
+ 3. POSTs the same event to /api/v1/producer-death-events so it's queryable
21
+ across every install from Postgres, not just grep-able on one box (see
22
+ migrations/2026-07-09-producer-death-events.sql).
23
+ 4. Emits a one-line summary via claude_job._plog() into provider.log,
24
+ which scripts/relay_provider_log.py already ships to Cloud Logging.
25
+
26
+ Three subcommands, so both Python (claude_job.py) and bash (run_claude.sh)
27
+ callers share one implementation:
28
+ arm — write the marker + spawn `watch` detached (start_new_session=True,
29
+ so it survives being in the same process group as the watched
30
+ pid if that group gets signaled). Called right before a caller
31
+ starts blocking on the watched pid.
32
+ disarm — remove the marker. Called on every normal return path AND from
33
+ a signal-trap cleanup (e.g. run_claude.sh's _sa_cleanup, which
34
+ itself SIGKILLs the watched process group as part of ordinary
35
+ TERM/INT/HUP handling — that must disarm too, or a normal
36
+ watchdog-triggered shutdown would misreport as an unexpected
37
+ death).
38
+ watch — the actual poll loop (internal; `arm` spawns this, nothing else
39
+ should call it directly).
40
+
41
+ Best-effort throughout: this is diagnostics only, never allowed to affect
42
+ the real job either way.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import json
48
+ import os
49
+ import subprocess
50
+ import sys
51
+ import time
52
+
53
+ HERE = os.path.dirname(os.path.abspath(__file__))
54
+ sys.path.insert(0, HERE)
55
+ from claude_job import queue_root, _plog # noqa: E402
56
+
57
+ POLL_S = 5.0
58
+
59
+
60
+ def arm_path(job_id: str) -> str:
61
+ return os.path.join(queue_root(), f"deathwatch-armed-{job_id}.marker")
62
+
63
+
64
+ def arm(watch_pid: int, job_id: str, qtype: str, batch: str, call_path: str) -> None:
65
+ """Write the marker and spawn a detached `watch` subprocess. Best-effort:
66
+ any failure here must never block the real caller."""
67
+ try:
68
+ marker = arm_path(job_id)
69
+ os.makedirs(queue_root(), exist_ok=True)
70
+ with open(marker, "w") as f:
71
+ f.write(str(watch_pid))
72
+ subprocess.Popen(
73
+ [sys.executable, os.path.abspath(__file__), "watch",
74
+ "--watch-pid", str(watch_pid), "--job-id", job_id,
75
+ "--qtype", qtype, "--batch", batch, "--call-path", call_path],
76
+ start_new_session=True,
77
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
78
+ )
79
+ except Exception:
80
+ pass
81
+
82
+
83
+ def disarm(job_id: str) -> None:
84
+ try:
85
+ os.remove(arm_path(job_id))
86
+ except Exception:
87
+ pass
88
+
89
+
90
+ def _snapshot() -> tuple[str, str]:
91
+ try:
92
+ vm = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5).stdout.strip()
93
+ except Exception as e:
94
+ vm = f"(vm_stat failed: {e})"
95
+ try:
96
+ procs = subprocess.run(
97
+ ["/bin/ps", "-axo", "pid=,ppid=,%mem=,%cpu=,command="],
98
+ capture_output=True, text=True, timeout=5,
99
+ ).stdout
100
+ related = "\n".join(
101
+ ln for ln in procs.splitlines()
102
+ if "claude" in ln or "run-twitter-cycle" in ln or "run_claude" in ln
103
+ ) or "(none matching claude/run-twitter-cycle/run_claude)"
104
+ except Exception as e:
105
+ related = f"(ps failed: {e})"
106
+ # Cap length: this rides into a Postgres text column and a JSON line;
107
+ # keep it bounded so a busy box's ps dump can't balloon either.
108
+ return vm[:4000], related[:4000]
109
+
110
+
111
+ def _report_to_db(event: dict) -> None:
112
+ """Best-effort POST to /api/v1/producer-death-events. Catches
113
+ BaseException, not just Exception: http_api._request raises SystemExit
114
+ on a terminal 4xx/5xx, which must never be allowed to break the
115
+ diagnostic path (mirrors autopilot_stall_watch.py's same guard)."""
116
+ try:
117
+ import http_api # noqa: E402 (sibling module, HERE already on sys.path)
118
+ http_api.api_post("/api/v1/producer-death-events", {
119
+ "watch_pid": event["watch_pid"],
120
+ "job_id": event["job_id"],
121
+ "batch_id": event["batch"] if event["batch"] != "-" else None,
122
+ "qtype": event["qtype"],
123
+ "call_path": event["call_path"],
124
+ "vm_stat_summary": event["vm_stat"],
125
+ "related_processes": event["related_processes"],
126
+ })
127
+ except BaseException:
128
+ pass
129
+
130
+
131
+ def watch(watch_pid: int, job_id: str, qtype: str, batch: str, call_path: str) -> int:
132
+ marker = arm_path(job_id)
133
+ while True:
134
+ if not os.path.exists(marker):
135
+ return 0 # disarmed: the caller returned cleanly, nothing to report
136
+ try:
137
+ os.kill(watch_pid, 0)
138
+ except ProcessLookupError:
139
+ break # pid gone but still armed -> unexpected death
140
+ except PermissionError:
141
+ pass # exists, just not signalable from here; keep watching
142
+ except Exception:
143
+ pass
144
+ time.sleep(POLL_S)
145
+
146
+ ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
147
+ vm, related = _snapshot()
148
+ event = {
149
+ "ts": ts,
150
+ "event": "unexpected_death",
151
+ "watch_pid": watch_pid,
152
+ "job_id": job_id,
153
+ "qtype": qtype,
154
+ "batch": batch,
155
+ "call_path": call_path,
156
+ "vm_stat": vm,
157
+ "related_processes": related,
158
+ }
159
+ try:
160
+ os.makedirs(queue_root(), exist_ok=True)
161
+ with open(os.path.join(queue_root(), "producer-deathwatch.jsonl"), "a") as f:
162
+ f.write(json.dumps(event) + "\n")
163
+ except Exception:
164
+ pass
165
+ _report_to_db(event)
166
+ try:
167
+ _plog(f"[deathwatch] UNEXPECTED DEATH watch_pid={watch_pid} job={job_id} "
168
+ f"type={qtype} batch={batch} path={call_path} -> see producer-deathwatch.jsonl")
169
+ except Exception:
170
+ pass
171
+ try:
172
+ os.remove(marker)
173
+ except Exception:
174
+ pass
175
+ return 0
176
+
177
+
178
+ def main() -> int:
179
+ ap = argparse.ArgumentParser()
180
+ sub = ap.add_subparsers(dest="cmd", required=True)
181
+
182
+ pa = sub.add_parser("arm")
183
+ pa.add_argument("--watch-pid", type=int, required=True)
184
+ pa.add_argument("--job-id", required=True)
185
+ pa.add_argument("--qtype", default="?")
186
+ pa.add_argument("--batch", default="-")
187
+ pa.add_argument("--call-path", default="queue", choices=["queue", "direct"])
188
+
189
+ pd = sub.add_parser("disarm")
190
+ pd.add_argument("--job-id", required=True)
191
+
192
+ pw = sub.add_parser("watch")
193
+ pw.add_argument("--watch-pid", type=int, required=True)
194
+ pw.add_argument("--job-id", required=True)
195
+ pw.add_argument("--qtype", default="?")
196
+ pw.add_argument("--batch", default="-")
197
+ pw.add_argument("--call-path", default="queue", choices=["queue", "direct"])
198
+
199
+ ns = ap.parse_args()
200
+ if ns.cmd == "arm":
201
+ arm(ns.watch_pid, ns.job_id, ns.qtype, ns.batch, ns.call_path)
202
+ return 0
203
+ if ns.cmd == "disarm":
204
+ disarm(ns.job_id)
205
+ return 0
206
+ return watch(ns.watch_pid, ns.job_id, ns.qtype, ns.batch, ns.call_path)
207
+
208
+
209
+ if __name__ == "__main__":
210
+ sys.exit(main())
@@ -200,6 +200,16 @@ _sa_cleanup() {
200
200
  rm -f "$SIDE_LOG"
201
201
  rm -f "$ACTIVE_FILE"
202
202
 
203
+ # Disarm the deathwatch (see the arm call in the retry loop below). This
204
+ # path SIGKILLs $CLAUDE_PG's process group a few lines down as ordinary
205
+ # TERM/INT/HUP handling (e.g. the watchdog killing a genuinely hung run),
206
+ # so it must disarm too — otherwise a normal forced shutdown would
207
+ # misreport as an unexpected death once that kill lands.
208
+ if [ -n "${_SA_DW_JOB:-}" ]; then
209
+ python3 "$REPO_DIR/scripts/producer_deathwatch.py" disarm --job-id "$_SA_DW_JOB" \
210
+ >/dev/null 2>&1 || true
211
+ fi
212
+
203
213
  # Sweep orphan claude descendants. Process groups survive the parent's
204
214
  # death (kids reparented to launchd keep their PGID), so killing
205
215
  # `kill -- -PGID` reaches every grandchild, including ones reparented
@@ -308,8 +318,25 @@ EOF
308
318
  { claude --session-id "$SESSION_ID" ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} "$@" | tee -a "$SIDE_LOG"; exit "${PIPESTATUS[0]}"; } &
309
319
  CLAUDE_PG=$!
310
320
  set +m
321
+ # Dead-man's-switch (2026-07-09): every non-queue-routed tag (reddit,
322
+ # linkedin, github, moltbook, instagram, dm-outreach-*, ...) blocks
323
+ # here exactly like claude_job.py's queue provider does, with the
324
+ # same silent-death risk (SIGKILL/OOM/hard crash while waiting). Arm
325
+ # per-attempt (job id includes $CLAUDE_PG so a retry never collides
326
+ # with a still-unwinding prior attempt's watcher); disarm right after
327
+ # `wait` returns AND from _sa_cleanup's trap (that path SIGKILLs
328
+ # $CLAUDE_PG itself as ordinary TERM/INT/HUP handling, which must
329
+ # disarm too or a normal watchdog-triggered shutdown would misreport
330
+ # as an unexpected death). See scripts/producer_deathwatch.py.
331
+ _SA_DW_JOB="${SESSION_ID}-${CLAUDE_PG}"
332
+ python3 "$REPO_DIR/scripts/producer_deathwatch.py" arm \
333
+ --watch-pid "$CLAUDE_PG" --job-id "$_SA_DW_JOB" --qtype "$SCRIPT_TAG" \
334
+ --batch "${BATCH_ID:-${SA_CYCLE_ID:--}}" --call-path direct \
335
+ >/dev/null 2>&1 || true
311
336
  wait "$CLAUDE_PG"
312
337
  RC=$?
338
+ python3 "$REPO_DIR/scripts/producer_deathwatch.py" disarm --job-id "$_SA_DW_JOB" \
339
+ >/dev/null 2>&1 || true
313
340
  if [ "$RC" -ne 127 ]; then
314
341
  break
315
342
  fi
@@ -266,6 +266,32 @@ def heal() -> dict:
266
266
  return summary
267
267
 
268
268
 
269
+ def can_create_for_active_account() -> bool:
270
+ """Read-only: would fix 5 (see heal()) actually be able to create a fresh
271
+ registration right now? True only if the active account (resolved the same
272
+ way heal() does, via schedule_state's config.json lookup) has at least one
273
+ EXISTING session directory to write into — fix 5 never fabricates one.
274
+ Used by callers (the menu bar) to decide whether to offer an automatic
275
+ "restart to finish setup" action or fall back to the manual re-arm prompt,
276
+ BEFORE committing to a restart that would turn out to fix nothing."""
277
+ try:
278
+ for cfg in schedule_state._config_json_paths():
279
+ root = os.path.dirname(cfg)
280
+ uuid = schedule_state._active_account_uuid(cfg)
281
+ if not uuid:
282
+ continue
283
+ account_dir = os.path.join(root, "claude-code-sessions", uuid)
284
+ session_dirs = [
285
+ p for p in glob.glob(os.path.join(account_dir, "*"))
286
+ if os.path.isdir(p)
287
+ ]
288
+ if session_dirs:
289
+ return True
290
+ except Exception:
291
+ pass
292
+ return False
293
+
294
+
269
295
  def main() -> int:
270
296
  out = heal()
271
297
  print(json.dumps(out))
@@ -2058,6 +2058,24 @@ else
2058
2058
  fi
2059
2059
  rm -f "$MEDIA_URLS_FILE" 2>/dev/null || true
2060
2060
 
2061
+ # Release the twitter-browser lock now. Thread-media capture above was the
2062
+ # ONLY browser-touching step in Phase 2b-prep; the Claude drafting call below
2063
+ # is architecturally browser-free (--strict-mcp-config omits the
2064
+ # twitter-harness MCP, so the model can never reach the CDP Chrome even if it
2065
+ # tried) and, since the 2026-06-23 queue migration, "run-twitter-cycle-prep"
2066
+ # routes through claude_job.py to an independent worker process that can
2067
+ # block for up to S4L_CLAUDE_QUEUE_TIMEOUT (1800s default). Holding the
2068
+ # browser lock across that wait made this process a preemption target for
2069
+ # any post that needed the browser: the post-vs-scan hijack fix SIGKILLs
2070
+ # whoever holds the lock (see docs/twitter_browser_lock.md), which killed
2071
+ # this process mid-wait while the worker kept drafting in the background,
2072
+ # stranding the finished result until the salvage reconciler's 35-min window
2073
+ # (scripts/salvage_orphaned_prep_results.py) picked it up. Releasing here
2074
+ # removes this whole phase from being a SIGKILL target for the rest of the
2075
+ # drafting wait. Phase 2b-post re-acquires unconditionally below.
2076
+ log "Releasing twitter-browser lock before Claude drafting (drafting never touches the browser)..."
2077
+ release_lock "twitter-browser" 2>>"$LOG_FILE"
2078
+
2061
2079
  # --- PERSONA CORPUS injection (personal_brand lane only) --------------------
2062
2080
  # build_persona.py apply writes a raw first-hand corpus sidecar next to
2063
2081
  # config.json. In the personal_brand lane we inline it so the drafter grounds
@@ -2432,30 +2450,12 @@ if [ "$PREP_PARSE_EXIT" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
2432
2450
  fi
2433
2451
  log "Phase 2b-prep complete. plan_count=$PLAN_COUNT"
2434
2452
 
2435
- # Determine if Phase 2b-gen will be a no-op. When TWITTER_PAGE_GEN_RATE=0
2436
- # globally, scripts/twitter_gen_links.py rewrites the plan with plain URLs in
2437
- # <1s. In that case the release-now + re-acquire-after-gen dance is pure waste:
2438
- # under cycle overlap the re-acquire can sit in the FIFO ticket queue for
2439
- # 30-90s behind the very `engage-twitter` / next `run-twitter-cycle` we just
2440
- # handed the lock to. We keep the lock through 2b-gen instead and skip the
2441
- # dance entirely.
2442
- GEN_RATE_RAW="${TWITTER_PAGE_GEN_RATE:-0.0}"
2443
- GEN_IS_NOOP=false
2444
- case "$GEN_RATE_RAW" in
2445
- 0|0.0|0.00|0.000|"") GEN_IS_NOOP=true ;;
2446
- esac
2447
-
2448
- # Release the lock unless (a) plan is non-empty AND (b) gen is a no-op. The
2449
- # empty-plan early-exit below still needs the release for a clean handoff, so
2450
- # we cannot just skip when GEN_IS_NOOP=true unconditionally.
2451
- if [ "${PLAN_COUNT:-0}" = "0" ] || ! $GEN_IS_NOOP; then
2452
- log "Releasing twitter-browser lock (gen step is lock-free)..."
2453
- release_lock "twitter-browser" 2>>"$LOG_FILE"
2454
- # (2026-06-16) session-lock rm removed (defect b); dead holders self-reclaim
2455
- # in twitter_browser.py now. Do NOT re-add. See Phase 1 note + docs/twitter_browser_lock.md.
2456
- else
2457
- log "Keeping twitter-browser lock through Phase 2b-gen (TWITTER_PAGE_GEN_RATE=$GEN_RATE_RAW, gen is a no-op; skipping release/re-acquire dance)"
2458
- fi
2453
+ # twitter-browser lock was already released right after thread-media capture
2454
+ # (before the Claude drafting call above), since nothing from there through
2455
+ # Phase 2b-gen touches the browser. Phase 2b-post re-acquires unconditionally
2456
+ # below. (2026-06-16) session-lock rm removed (defect b); dead holders
2457
+ # self-reclaim in twitter_browser.py now. Do NOT re-add. See Phase 1 note +
2458
+ # docs/twitter_browser_lock.md.
2459
2459
 
2460
2460
  if [ "${PLAN_COUNT:-0}" = "0" ]; then
2461
2461
  log "Empty plan from prep step. Exiting cycle without posting (pending rows salvaged next cycle)."
@@ -2575,12 +2575,10 @@ fi
2575
2575
  # 2b-gen's potentially long run, peer cycles' 20-min phase2a fallback would
2576
2576
  # already be tripping if we left the row at phase2a.
2577
2577
  python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2b-post 2>&1 | tee -a "$LOG_FILE" || true
2578
- # Re-acquire only if we actually released for gen (see GEN_IS_NOOP above).
2579
- # When the lock was kept through 2b-gen there's nothing to re-acquire.
2580
- if ! $GEN_IS_NOOP; then
2581
- log "Re-acquiring twitter-browser lock for Phase 2b-post..."
2582
- acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
2583
- fi
2578
+ # Always re-acquire: the lock was released right after thread-media capture
2579
+ # (before Claude drafting), well before 2b-gen, so it is never still held here.
2580
+ log "Re-acquiring twitter-browser lock for Phase 2b-post..."
2581
+ acquire_lock "twitter-browser" 3600 2>>"$LOG_FILE"
2584
2582
  log "twitter-browser lock held (pid=$$) Phase 2b-post"
2585
2583
  # Drop stale singleton locks (see clean_stale_singleton.sh, also called in Phase 1 / 2b-prep).
2586
2584
  ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"