← back to Cncp Mcp
run-on-mac1: use ssh-config alias 'mac1' + add homebrew PATH prep
1e24705bdf399ded32fbda2cfdd255d54da3e562 · 2026-05-11 08:40:55 -0700 · Steve Abrams
Files touched
A scripts/apply-pm2-caps.sh
Diff
commit 1e24705bdf399ded32fbda2cfdd255d54da3e562
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 08:40:55 2026 -0700
run-on-mac1: use ssh-config alias 'mac1' + add homebrew PATH prep
---
scripts/apply-pm2-caps.sh | 154 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 154 insertions(+)
diff --git a/scripts/apply-pm2-caps.sh b/scripts/apply-pm2-caps.sh
new file mode 100644
index 0000000..c98b8a2
--- /dev/null
+++ b/scripts/apply-pm2-caps.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+# apply-pm2-caps.sh — generate ecosystem.config.cjs + apply memory caps for any
+# pm2 proc that's currently uncapped. Per-project, idempotent, dry-run default.
+#
+# Why: Steve has 21+ pm2 procs without ecosystem.config files → no
+# max_memory_restart → any leaking proc drags Mac2 load to 500+.
+#
+# Usage:
+# bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh # dry-run (default)
+# bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh --write # write ecosystem.config.cjs files only
+# bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh --cycle # write + delete/restart each proc serially
+#
+# What it does:
+# --write : creates ecosystem.config.cjs in each proc's cwd (only if missing)
+# with the proc's current script + name + port env + cap. Doesn't restart.
+# --cycle : --write, then for each proc: pm2 delete → pm2 start ecosystem.config.cjs
+# with a 2s gap between procs to avoid load spike. pm2 save at end.
+#
+# Safety:
+# - Never overwrites an existing ecosystem.config.{js,cjs}.
+# - Default cap is 300M for sister-sites; can override via SISTER_CAP env.
+# - Skips procs whose script path can't be resolved.
+# - Logs every action to /tmp/pm2-caps-<ts>.log.
+
+set -u
+
+MODE="dry"
+for arg in "$@"; do
+ case "$arg" in
+ --write) MODE="write" ;;
+ --cycle) MODE="cycle" ;;
+ --dry-run) MODE="dry" ;;
+ *) echo "Unknown arg: $arg" >&2; exit 2 ;;
+ esac
+done
+
+SISTER_CAP="${SISTER_CAP:-300M}"
+LLM_CAP="${LLM_CAP:-1G}"
+PM2_BIN="${PM2_BIN:-$HOME/.npm-global/bin/pm2}"
+[[ -x "$PM2_BIN" ]] || PM2_BIN="$(/usr/bin/which pm2 2>/dev/null || echo pm2)"
+TS="$(date +%Y%m%d-%H%M%S)"
+LOG="/tmp/pm2-caps-${TS}.log"
+
+log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$LOG"; }
+
+log "mode=$MODE sister-cap=$SISTER_CAP llm-cap=$LLM_CAP"
+
+"$PM2_BIN" jlist 2>/dev/null > "/tmp/pm2-jlist-${TS}.json" || {
+ log "ERROR: pm2 jlist failed"
+ exit 3
+}
+
+/usr/bin/python3 <<PYEOF
+import json, os, sys, subprocess, time
+
+MODE = "$MODE"
+SISTER_CAP = "$SISTER_CAP"
+LLM_CAP = "$LLM_CAP"
+LOG = "$LOG"
+PM2 = "$PM2_BIN"
+
+def logmsg(s):
+ line = f"[{time.strftime('%H:%M:%S', time.gmtime())}] {s}"
+ print(line)
+ with open(LOG, 'a') as f: f.write(line + "\n")
+
+procs = json.load(open("/tmp/pm2-jlist-${TS}.json"))
+
+# Identify candidates: no max_memory_restart, has a valid pm_cwd + pm_exec_path
+todo = []
+for p in procs:
+ env = p.get('pm2_env', {})
+ name = p.get('name', '?')
+ if env.get('max_memory_restart'): continue
+ pm_cwd = env.get('pm_cwd', '') or ''
+ pm_exec_path = env.get('pm_exec_path', '') or ''
+ if not pm_cwd or not pm_exec_path:
+ logmsg(f"SKIP {name}: missing cwd or script path")
+ continue
+ if not os.path.isfile(pm_exec_path):
+ logmsg(f"SKIP {name}: script path doesn't exist: {pm_exec_path}")
+ continue
+ # Try to extract PORT from env
+ port = env.get('PORT') or env.get('port') or ''
+ # Decide cap: LLM agents get more, sister-sites less
+ name_low = name.lower()
+ is_llm = any(k in name_low for k in ('agent', 'gpt', 'llm', 'codex', 'claude', 'orchestrator', 'factory', 'debrief'))
+ cap = LLM_CAP if is_llm else SISTER_CAP
+ todo.append({'name': name, 'cwd': pm_cwd, 'script': pm_exec_path, 'port': port, 'cap': cap})
+
+logmsg(f"candidates: {len(todo)}")
+for t in todo:
+ logmsg(f" {t['name']:<32} cwd={t['cwd']} script={os.path.basename(t['script'])} cap={t['cap']}")
+
+if MODE == "dry":
+ logmsg("DRY-RUN — no files written, no pm2 changes. Re-run with --write or --cycle.")
+ sys.exit(0)
+
+# --write : create ecosystem.config.cjs in each cwd if missing
+created = 0
+already = 0
+for t in todo:
+ eco_cjs = os.path.join(t['cwd'], 'ecosystem.config.cjs')
+ eco_js = os.path.join(t['cwd'], 'ecosystem.config.js')
+ if os.path.isfile(eco_cjs) or os.path.isfile(eco_js):
+ logmsg(f"already-has-ecosystem {t['name']}")
+ already += 1
+ continue
+ # Build the script body
+ script_basename = os.path.basename(t['script'])
+ env_block = f" PORT: {t['port']},\n" if t['port'] else ""
+ content = f"""module.exports = {{
+ apps: [{{
+ name: "{t['name']}",
+ script: "{script_basename}",
+ cwd: __dirname,
+ env: {{
+{env_block} }},
+ autorestart: true,
+ max_memory_restart: "{t['cap']}",
+ }}],
+}};
+"""
+ try:
+ with open(eco_cjs, 'w') as f: f.write(content)
+ logmsg(f"WROTE {eco_cjs}")
+ created += 1
+ except Exception as e:
+ logmsg(f"WRITE-FAIL {t['name']}: {e}")
+
+logmsg(f"--write done: {created} created, {already} already had one.")
+
+if MODE == "cycle":
+ logmsg("--cycle: delete + restart each, 2s gap")
+ for t in todo:
+ eco = os.path.join(t['cwd'], 'ecosystem.config.cjs')
+ if not os.path.isfile(eco):
+ logmsg(f" skip {t['name']}: no ecosystem.config.cjs")
+ continue
+ logmsg(f" cycle {t['name']}")
+ subprocess.run([PM2, "delete", t['name']], capture_output=True)
+ r = subprocess.run([PM2, "start", eco], capture_output=True, text=True)
+ if r.returncode != 0:
+ logmsg(f" START FAIL: {r.stderr[:200]}")
+ else:
+ logmsg(f" started")
+ time.sleep(2)
+ logmsg("running pm2 save")
+ subprocess.run([PM2, "save"], capture_output=True)
+ logmsg("--cycle done.")
+PYEOF
+
+echo ""
+echo "Log: $LOG"
← 0239c2e load-spreading: run-on-mac1 wrapper + pm2-cap audit script
·
back to Cncp Mcp
·
load-aware dispatch: run-on-mac1 routes Mac2↔Mac1 by load + e6e3182 →