social-autoposter 1.6.136 → 1.6.138

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
@@ -2039,8 +2039,14 @@ async function autopilotLoaded() {
2039
2039
  // fires every minute, claims ONE job, runs the pipeline's own prompt as its
2040
2040
  // Claude turn, writes the result back, and stops.
2041
2041
  // ===========================================================================
2042
- const QUEUE_WORKER_PROMPT_VERSION = 2;
2042
+ const QUEUE_WORKER_PROMPT_VERSION = 4; // v4: tighten per-run draft cap to 2 (v3's 3 still overran the ~100-134s kill window on heavy cycles, ~20% timeouts)
2043
2043
  const QUEUE_WORKER_PROMPT_MARKER = "saps_queue_worker_prompt_version";
2044
+ // Max replies the Phase-2b draft worker may draft in ONE scheduled run. The host
2045
+ // SIGTERMs scheduled sessions after ~90-110s; drafting more than a handful never
2046
+ // submits in time. Start conservative so a run always finishes inside the window;
2047
+ // undrafted candidates stay "pending" and return next cycle (lossless). Can be
2048
+ // raised once drains are confirmed reliably under the kill budget.
2049
+ const DRAFT_BUDGET_CANDIDATES = 2;
2044
2050
  // One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
2045
2051
  const QUEUE_WORKERS = [
2046
2052
  { taskId: PHASE1_TASK_ID, queueType: "twitter-query", human: "Phase 1 X search-query drafting" },
@@ -2142,6 +2148,17 @@ function queueWorkerBody(spec) {
2142
2148
  const job = path.join(repoDir(), "scripts", "claude_job.py");
2143
2149
  const sd = sapsStateDir();
2144
2150
  const outDir = queueDir();
2151
+ // The Phase-2b "twitter-prep" worker drafts replies inside the scheduled
2152
+ // session, which the host hard-kills (SIGTERM) after ~90-110s of wall time.
2153
+ // Drafting all of one cycle's candidates blows past that every time, so the
2154
+ // job is never submitted and times out (see provider.log drain timeouts).
2155
+ // Cap how many it drafts per run so it always finishes and submits in time;
2156
+ // the inner prompt already treats undrafted candidates as "pending" timing
2157
+ // deferrals, so they come back next cycle (lossless, self-healing).
2158
+ const isDraft = spec.queueType === "twitter-prep";
2159
+ const draftBudgetNote = isDraft
2160
+ ? ` Per the TIME BUDGET above, draft AT MOST the first ${DRAFT_BUDGET_CANDIDATES} candidates and OMIT the rest; do not attempt them all.`
2161
+ : "";
2145
2162
  return [
2146
2163
  `You are the S4L "${spec.human}" queue worker. Run ONE iteration, then STOP.`,
2147
2164
  ``,
@@ -2151,6 +2168,26 @@ function queueWorkerBody(spec) {
2151
2168
  `You do this with Bash and Write, and NOTHING else. This run is unattended — ` +
2152
2169
  `reaching for any other tool, or trying to "investigate", STALLS it forever.`,
2153
2170
  ``,
2171
+ ...(isDraft
2172
+ ? [
2173
+ `TIME BUDGET (read before step 2; this OVERRIDES anything the inner prompt ` +
2174
+ `says about "no cap" or drafting "every" candidate): this unattended ` +
2175
+ `session is HARD-KILLED after about 90 seconds. You physically cannot ` +
2176
+ `draft more than a few replies before that kill, and if you are killed ` +
2177
+ `mid-draft the pipeline receives NOTHING and the whole cycle is wasted. ` +
2178
+ `So work to this rule: draft replies for AT MOST the first ` +
2179
+ `${DRAFT_BUDGET_CANDIDATES} candidates in the prompt (they are pre-sorted ` +
2180
+ `strongest-first), then IMMEDIATELY do step 3 (write your result and ` +
2181
+ `submit). Every candidate you do not draft is a TIMING DEFERRAL, NOT a ` +
2182
+ `rejection: OMIT it from BOTH the "candidates" and "rejected" arrays. The ` +
2183
+ `inner prompt explicitly allows this (omitted candidates stay "pending" ` +
2184
+ `and a later run re-judges them, so nothing is lost). A valid result with ` +
2185
+ `${DRAFT_BUDGET_CANDIDATES} or FEWER solid drafts that you submit IN TIME ` +
2186
+ `is a SUCCESS; being killed with nothing submitted is the only failure. ` +
2187
+ `When unsure, draft fewer and submit sooner.`,
2188
+ ``,
2189
+ ]
2190
+ : []),
2154
2191
  `Steps:`,
2155
2192
  `1. Claim the next job. Run this EXACT Bash command:`,
2156
2193
  ` ${py} ${job} next --type ${spec.queueType} --prompt-file --state-dir ${sd}`,
@@ -2162,7 +2199,7 @@ function queueWorkerBody(spec) {
2162
2199
  `or truncated, keep reading the same file with offsets until EOF. If schema_file ` +
2163
2200
  `is not null, read it too. Follow the prompt EXACTLY and produce the SINGLE JSON ` +
2164
2201
  `object it asks for. If a schema is present, your JSON MUST satisfy it. Output ` +
2165
- `ONLY that JSON object — no prose, no markdown, no code fences.`,
2202
+ `ONLY that JSON object — no prose, no markdown, no code fences.${draftBudgetNote}`,
2166
2203
  `3. Submit it. Write your JSON object to ${outDir}/out-<job_id>.json using the ` +
2167
2204
  `Write tool (substitute the real job_id), then run this EXACT Bash command:`,
2168
2205
  ` ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.136",
3
- "installedAt": "2026-06-29T04:09:39.530Z"
2
+ "version": "1.6.138",
3
+ "installedAt": "2026-06-29T18:00:31.054Z"
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.136",
5
+ "version": "1.6.138",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "The disclaimer above is generic Claude boilerplate. S4L 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 end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
@@ -133,6 +133,12 @@ REARM_PROMPT = (
133
133
  # scheduler's per-minute cadence + a slow claim.
134
134
  AUTOPILOT_STALL_SECONDS = 180
135
135
 
136
+ # A job CLAIMED but never finished (sits in running/ this long) means a worker
137
+ # picked it up and then wedged mid-run (the claude -p drafting child died / never
138
+ # spawned). Generous enough that the longest real drafting turn never trips it.
139
+ # Keep in sync with RUNNING_STALL_SECONDS (scripts/autopilot_stall_watch.py).
140
+ AUTOPILOT_RUNNING_STALL_SECONDS = 900
141
+
136
142
  # A worker task whose lastRunAt is within this many seconds is "firing" — the host
137
143
  # scheduler runs them every minute, so a fresh stamp means the live account's
138
144
  # schedule is active. 7 min tolerates host throttling + a restart gap without
@@ -452,6 +458,18 @@ class S4LMenuBar(rumps.App):
452
458
  (2) FAST: a draft job has sat unclaimed in pending/ past
453
459
  AUTOPILOT_STALL_SECONDS -> catches a fresh stall ~3 min in, before
454
460
  the first full producer timeout has even latched (1).
461
+ (3) IN-FLIGHT: a draft job was claimed (moved to running/) but never
462
+ finished within AUTOPILOT_RUNNING_STALL_SECONDS -> the worker picked
463
+ it up and then wedged mid-run. Self-clearing (the file is removed on
464
+ result or swept next cycle), so unlike the abandoned drain latch it
465
+ does NOT stay stale after recovery.
466
+
467
+ NOTE: kept in sync with scripts/autopilot_stall_watch.py (the fleet Sentry
468
+ backstop). The menu-bar ⚠ itself is driven by _schedule_state, NOT this
469
+ method — the attention/⚠ path keys off schedule_state so a firing-but-
470
+ momentarily-empty queue stays green (an earlier drain-latch ⚠ stayed stale
471
+ after recovery and was deliberately removed). This method exists for the
472
+ watcher-parity contract and _stall_reason.
455
473
  """
456
474
  qroot = os.path.join(st.state_dir(), "claude-queue")
457
475
  # (1) latched producer drain-status
@@ -478,6 +496,22 @@ class S4LMenuBar(rumps.App):
478
496
  return True
479
497
  except Exception:
480
498
  pass
499
+ # (3) in-flight running-age (claimed then wedged). running/ is flat.
500
+ try:
501
+ oldest = None
502
+ for jf in glob.glob(os.path.join(qroot, "running", "*.json")):
503
+ if jf.endswith(".tmp"):
504
+ continue
505
+ try:
506
+ m = os.path.getmtime(jf)
507
+ except OSError:
508
+ continue
509
+ if oldest is None or m < oldest:
510
+ oldest = m
511
+ if oldest is not None and (time.time() - oldest) > AUTOPILOT_RUNNING_STALL_SECONDS:
512
+ return True
513
+ except Exception:
514
+ pass
481
515
  return False
482
516
 
483
517
  def _recent_worker_outcome(self, window=600):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.136",
3
+ "version": "1.6.138",
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": "social-autoposter",
3
- "version": "1.6.136",
3
+ "version": "1.6.138",
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"
@@ -0,0 +1,37 @@
1
+ import asyncio, sys
2
+ from playwright.async_api import async_playwright
3
+
4
+ PROFILE = "https://x.com/Web3Mohsin"
5
+ STATUS = "https://x.com/Web3Mohsin/status/2071449607092183151"
6
+
7
+ async def grab(page, url, shot):
8
+ await page.goto(url, wait_until="domcontentloaded")
9
+ await page.wait_for_timeout(6000)
10
+ try:
11
+ txt = await page.inner_text("body")
12
+ except Exception as e:
13
+ txt = f"<no body text: {e}>"
14
+ await page.screenshot(path=shot, full_page=False)
15
+ return txt
16
+
17
+ async def main():
18
+ async with async_playwright() as p:
19
+ b = await p.chromium.connect_over_cdp("http://127.0.0.1:9555")
20
+ ctx = b.contexts[0]
21
+ page = await ctx.new_page()
22
+ who = ""
23
+ try:
24
+ await page.goto("https://x.com/home", wait_until="domcontentloaded")
25
+ await page.wait_for_timeout(3000)
26
+ except Exception as e:
27
+ who = f"<home err {e}>"
28
+ prof = await grab(page, PROFILE, "/tmp/blk_profile.png")
29
+ stat = await grab(page, STATUS, "/tmp/blk_status.png")
30
+ print("==== PROFILE URL:", page.url)
31
+ print("==== PROFILE TEXT (first 1500) ====")
32
+ print(prof[:1500])
33
+ print("\n==== STATUS TEXT (first 1500) ====")
34
+ print(stat[:1500])
35
+ await page.close()
36
+
37
+ asyncio.run(main())
@@ -40,6 +40,12 @@ import time
40
40
 
41
41
  # Keep in sync with AUTOPILOT_STALL_SECONDS (menubar) / AUTOPILOT_STALL_MS (index.ts).
42
42
  STALL_SECONDS = 180
43
+ # A job CLAIMED but never finished (sits in running/ this long) means a worker
44
+ # picked it up and then died mid-run — the claude -p drafting child never came up
45
+ # or crashed. Must be generous enough to clear the longest real drafting turn so a
46
+ # healthy run never trips it. Keep in sync with AUTOPILOT_RUNNING_STALL_SECONDS
47
+ # (menubar). See _oldest_running_age.
48
+ RUNNING_STALL_SECONDS = 900
43
49
  # Require the stall to persist this many consecutive checks before paging, so a
44
50
  # transient slow claim (e.g. right after a Claude restart) doesn't false-alarm.
45
51
  # At StartInterval 120 that is ~6 min of continuous stall.
@@ -136,6 +142,30 @@ def _oldest_pending_age() -> float | None:
136
142
  return time.time() - oldest
137
143
 
138
144
 
145
+ def _oldest_running_age() -> float | None:
146
+ """Seconds since the oldest CLAIMED-but-unfinished job was written, or None if
147
+ nothing is in flight. A worker claims by moving a job pending/ -> running/ and
148
+ only removes it on result, so a job lingering in running/ far past any real
149
+ drafting turn means the worker claimed it and then wedged mid-run (dead/never-
150
+ spawned claude -p child). This is the ONLY signal for that case: pending-age is
151
+ silent (the job left pending/) and the producer's drain latch hasn't fired yet
152
+ (it's still inside its own timeout). running/ is flat (see claude_job.py)."""
153
+ run_root = os.path.join(_queue_root(), "running")
154
+ oldest = None
155
+ for jf in glob.glob(os.path.join(run_root, "*.json")):
156
+ if jf.endswith(".tmp"):
157
+ continue
158
+ try:
159
+ m = os.path.getmtime(jf)
160
+ except OSError:
161
+ continue
162
+ if oldest is None or m < oldest:
163
+ oldest = m
164
+ if oldest is None:
165
+ return None
166
+ return time.time() - oldest
167
+
168
+
139
169
  def _read_state() -> dict:
140
170
  try:
141
171
  with open(_watch_state_path()) as f:
@@ -169,11 +199,16 @@ def _sentry():
169
199
 
170
200
  def main() -> int:
171
201
  age = _oldest_pending_age()
202
+ run_age = _oldest_running_age()
172
203
  timeouts = _consecutive_timeouts()
173
- # Durable latch OR fast pending-age (see the two helpers). Either alone is a
174
- # stall; both must be gated on the autopilot actually being configured here.
204
+ # Three complementary signals, OR'd; all gated on the autopilot actually being
205
+ # configured here. (1) durable producer drain latch, (2) fast pending-age (job
206
+ # never claimed), (3) running-age (job claimed then wedged mid-run). (3) is the
207
+ # only one that catches a worker dying after it picked up the job.
175
208
  stalled = _autopilot_configured() and (
176
- timeouts >= 1 or (age is not None and age > STALL_SECONDS)
209
+ timeouts >= 1
210
+ or (age is not None and age > STALL_SECONDS)
211
+ or (run_age is not None and run_age > RUNNING_STALL_SECONDS)
177
212
  )
178
213
  # A rate-limit stall is expected and self-heals at the quota reset — never page
179
214
  # for it (and re-arm can't fix it). Treat it as "not an actionable stall" so the
@@ -193,21 +228,33 @@ def main() -> int:
193
228
 
194
229
  consecutive += 1
195
230
  age_str = f"{int(age)}s" if age is not None else "n/a (between cycles)"
231
+ run_age_str = f"{int(run_age)}s" if run_age is not None else "n/a (none in flight)"
232
+ # Distinguish the two shapes so the alert points at the right cause: a claimed-
233
+ # but-wedged job (running-age) is a mid-run worker death, not an orphaned routine.
234
+ wedged_inflight = run_age is not None and run_age > RUNNING_STALL_SECONDS
196
235
  if consecutive >= ALERT_AFTER and not alerted:
197
236
  try:
198
237
  sentry = _sentry()
199
238
  sentry.init()
239
+ cause = (
240
+ "a worker claimed a draft job and then died mid-run (claude -p child "
241
+ "never came up / crashed)"
242
+ if wedged_inflight
243
+ else "scheduled-task routines likely orphaned — Claude Desktop account change?"
244
+ )
200
245
  sentry.capture_message(
201
246
  "social-autoposter autopilot stalled: draft jobs are not being "
202
- "drained (scheduled-task routines likely orphaned — Claude Desktop "
203
- f"account change?). producer consecutive timeouts={timeouts}, "
204
- f"oldest pending job age={age_str}, sustained {consecutive} checks.",
247
+ f"drained ({cause}). producer consecutive timeouts={timeouts}, "
248
+ f"oldest pending job age={age_str}, oldest in-flight (running) job "
249
+ f"age={run_age_str}, sustained {consecutive} checks.",
205
250
  level="error",
206
251
  tags={
207
252
  "component": "autopilot",
208
253
  "issue": "stall",
254
+ "stall_shape": "inflight_wedged" if wedged_inflight else "not_draining",
209
255
  "consecutive_timeouts": str(timeouts),
210
256
  "oldest_pending_age_s": str(int(age)) if age is not None else "",
257
+ "oldest_running_age_s": str(int(run_age)) if run_age is not None else "",
211
258
  },
212
259
  )
213
260
  sentry.flush()
@@ -215,7 +262,7 @@ def main() -> int:
215
262
  # No Sentry (helper/SDK missing) -> at least leave a local breadcrumb.
216
263
  sys.stderr.write(
217
264
  f"[stall-watch] autopilot stalled (timeouts={timeouts}, "
218
- f"age={age_str}) but Sentry report failed\n"
265
+ f"pending_age={age_str}, running_age={run_age_str}) but Sentry report failed\n"
219
266
  )
220
267
  alerted = True
221
268
 
@@ -86,6 +86,43 @@ def _act_clear() -> None:
86
86
  except Exception:
87
87
  pass
88
88
 
89
+
90
+ def _fmt_dur(secs: float) -> str:
91
+ """Compact human duration for the menu-bar label: '45s', '12m'."""
92
+ s = int(max(0, secs))
93
+ return f"{s}s" if s < 60 else f"{s // 60}m"
94
+
95
+
96
+ def _act_write_progress(
97
+ qtype: str, created: float, claimed_at: float | None, now: float
98
+ ) -> None:
99
+ """Granular in-flight menu-bar label, so a wedged cycle reads as the TRUTH
100
+ instead of a static 'drafting replies' that lingers for the whole producer
101
+ timeout (the failure mode where the worker never claims the job, or claims it
102
+ and dies mid-run, looked identical to healthy drafting before this).
103
+
104
+ - job still in pending/ (no worker has claimed it) -> '<base> (queued <dur>)'
105
+ counting from enqueue. A growing 'queued 18m' is the unmistakable tell that
106
+ a scheduled-task worker is orphaned and nothing is draining.
107
+ - job claimed (pending file gone -> moved to running/) -> '<base> (<dur>)'
108
+ counting from the claim, i.e. real drafting elapsed.
109
+
110
+ Purely cosmetic and best-effort: a write failure must never affect the queue."""
111
+ if _activity is None:
112
+ return
113
+ sl = TYPE_TO_ACTIVITY.get(qtype)
114
+ if not sl:
115
+ return
116
+ state, base = sl
117
+ if claimed_at is None:
118
+ label = f"{base} (queued {_fmt_dur(now - created)})"
119
+ else:
120
+ label = f"{base} ({_fmt_dur(now - claimed_at)})"
121
+ try:
122
+ _activity.write(state, label)
123
+ except Exception:
124
+ pass
125
+
89
126
  # claude flags that consume the following argv token as their value, so the
90
127
  # value is never mistaken for the positional prompt.
91
128
  VALUE_FLAGS = {
@@ -499,17 +536,27 @@ def cmd_provider(ns) -> int:
499
536
  res_path = os.path.join(result_dir(), f"{job_id}.json")
500
537
  deadline = created + ns.timeout
501
538
  last_hb = created # last menu-bar heartbeat (see below)
539
+ claimed_at = None # set the moment a worker moves the job pending/ -> running/
502
540
  while time.time() < deadline:
541
+ now = time.time()
542
+ # A worker claims a job by atomically renaming pending/ -> running/, so the
543
+ # pending file vanishing is our signal that drafting actually STARTED (vs.
544
+ # the job still sitting unclaimed). Latch the claim time once so the label
545
+ # can distinguish "waiting for a worker" from "worker is drafting" and show
546
+ # the right elapsed for each.
547
+ if claimed_at is None and not os.path.exists(pending_path):
548
+ claimed_at = now
503
549
  # Heartbeat the menu-bar label so its `since` stays fresh for the whole
504
550
  # multi-minute block. The consumer (s4l_state.read_activity) ages a label
505
551
  # out after a TTL, so without this refresh a long drafting turn would look
506
552
  # stale and the spinner would wrongly blink to idle. Refreshing here means
507
553
  # the label is fresh EXACTLY while real work is happening, and stops the
508
554
  # instant we return or die — so the consumer's TTL can then expire it
509
- # instead of it freezing forever. Throttled to ~10s; best-effort only.
510
- now = time.time()
555
+ # instead of it freezing forever. Throttled to ~10s; best-effort only. The
556
+ # label now carries claim-state + elapsed so a stuck cycle reads honestly
557
+ # ("queued 18m") instead of a reassuring static "drafting replies".
511
558
  if now - last_hb >= 10:
512
- _act_write(qtype)
559
+ _act_write_progress(qtype, created, claimed_at, now)
513
560
  last_hb = now
514
561
  if os.path.exists(res_path):
515
562
  try:
@@ -169,6 +169,7 @@ def parse_vm_stat() -> dict[str, Any]:
169
169
  "inactive_mb": pages_mb("pages_inactive"),
170
170
  "speculative_mb": pages_mb("pages_speculative"),
171
171
  "wired_mb": pages_mb("pages_wired_down"),
172
+ "purgeable_mb": pages_mb("pages_purgeable"),
172
173
  "compressed_mb": pages_mb("pages_occupied_by_compressor"),
173
174
  "swapins": pages.get("swapins"),
174
175
  "swapouts": pages.get("swapouts"),
@@ -176,6 +177,30 @@ def parse_vm_stat() -> dict[str, Any]:
176
177
  }
177
178
 
178
179
 
180
+ def memory_pressure_pct_free() -> float | None:
181
+ """macOS-authoritative availability signal.
182
+
183
+ `memory_pressure` prints "System-wide memory free percentage: N%". THIS is the
184
+ number to trust for "is the box starved?" — NOT vm_stat "pages free", which sits
185
+ near-zero by design (macOS hoards RAM as cached/inactive/compressed pages, so a
186
+ tiny "free" is the normal healthy state, not starvation). Best-effort; returns
187
+ None if the tool is unavailable so the caller can fall back to a pages estimate.
188
+ """
189
+ out = run(["/usr/bin/memory_pressure"], timeout=6.0)
190
+ if not out:
191
+ return None
192
+ m = re.search(r"free percentage:\s*([\d.]+)", out)
193
+ return round(float(m.group(1)), 1) if m else None
194
+
195
+
196
+ def swap_used_mb() -> float | None:
197
+ """Active swap in MB from vm.swapusage (a real-pressure corroborator)."""
198
+ sysctl_bin = "/usr/sbin/sysctl" if Path("/usr/sbin/sysctl").exists() else "sysctl"
199
+ out = run([sysctl_bin, "-n", "vm.swapusage"], timeout=2.0)
200
+ m = re.search(r"used\s*=\s*([\d.]+)M", out or "")
201
+ return round(float(m.group(1)), 1) if m else None
202
+
203
+
179
204
  def launchd_jobs(by_pid: dict[int, dict[str, Any]], children: dict[int, list[int]]) -> list[dict[str, Any]]:
180
205
  out = run(["launchctl", "list"], timeout=5.0)
181
206
  jobs: list[dict[str, Any]] = []
@@ -601,12 +626,42 @@ def build_summary() -> dict[str, Any]:
601
626
  rows, by_pid, children = parse_ps()
602
627
  mem = parse_vm_stat()
603
628
  total = mem.get("total_mb")
604
- free = mem.get("free_mb")
629
+ # macOS memory accounting: "available" headroom is what can be handed to a
630
+ # process WITHOUT paging — free + inactive + speculative + purgeable, all of
631
+ # which the OS reclaims on demand. The real footprint is total - available.
632
+ # Do NOT use vm_stat "pages free" as the headline: it is near-zero by design
633
+ # (macOS keeps RAM full of reclaimable cache), so total-minus-free reads ~99%
634
+ # and falsely looks like starvation. That trap caused a wrong OOM call once.
635
+ avail_parts = [mem.get(k) for k in ("free_mb", "inactive_mb", "speculative_mb", "purgeable_mb")]
636
+ available = (
637
+ round(sum(p for p in avail_parts if isinstance(p, (int, float))), 1) if mem else None
638
+ )
605
639
  used = (
606
- round(float(total) - float(free), 1)
607
- if isinstance(total, (int, float)) and isinstance(free, (int, float))
640
+ round(float(total) - float(available), 1)
641
+ if isinstance(total, (int, float)) and isinstance(available, (int, float))
642
+ else None
643
+ )
644
+ # pct_free is kept CONSISTENT with the MB figures above (available / total) so a
645
+ # reader never sees two contradictory percentages. `pressure_pct` is the separate
646
+ # OS pressure gauge from `memory_pressure` (counts evictable file cache as free, so
647
+ # it reads higher) — it is the most robust starvation detector, so `health` is
648
+ # derived from it, falling back to pct_free only when the tool is unavailable.
649
+ pct_free = (
650
+ round(available / total * 100, 1)
651
+ if isinstance(total, (int, float)) and isinstance(available, (int, float)) and total
608
652
  else None
609
653
  )
654
+ pressure_pct = memory_pressure_pct_free()
655
+ basis = pressure_pct if pressure_pct is not None else pct_free
656
+ if basis is None:
657
+ health = "unknown"
658
+ elif basis < 10:
659
+ health = "critical"
660
+ elif basis < 20:
661
+ health = "warn"
662
+ else:
663
+ health = "ok"
664
+ swap_used = swap_used_mb()
610
665
  slim_groups = {
611
666
  name: {"count": g["count"], "rss_mb": g["rss_mb"]}
612
667
  for name, g in group_summaries(rows).items()
@@ -634,9 +689,13 @@ def build_summary() -> dict[str, Any]:
634
689
  "mem": {
635
690
  "total_mb": total,
636
691
  "used_mb": used,
637
- "free_mb": free,
692
+ "available_mb": available,
693
+ "pct_free": pct_free,
694
+ "pressure_pct": pressure_pct,
695
+ "health": health,
638
696
  "wired_mb": mem.get("wired_mb"),
639
697
  "compressed_mb": mem.get("compressed_mb"),
698
+ "swap_used_mb": swap_used,
640
699
  "swapouts": mem.get("swapouts"),
641
700
  },
642
701
  "groups": slim_groups,