social-autoposter 1.6.6 → 1.6.7

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/bin/cli.js CHANGED
@@ -170,19 +170,72 @@ function writeAppMakerEnvFile() {
170
170
  console.log(` AppMaker VM detected -> wrote ${envPath} (TWITTER_CDP_URL=http://127.0.0.1:9222)`);
171
171
  }
172
172
 
173
+ // AppMaker VMs also need the twitter-harness MCP server (browser-harness/server.py)
174
+ // to drive port 9222, not its default 9555. That's a SECOND path the env file alone
175
+ // doesn't cover, because the MCP server is spawned by Claude as a subprocess with
176
+ // an env block taken from the MCP config file (--strict-mcp-config replaces the
177
+ // inherited env, so a parent BH_PORT export wouldn't reach it). So we patch the
178
+ // MCP config in-place to bake BH_PORT=9222 into its env block.
179
+ // Idempotent: parses the JSON, sets env.BH_PORT, rewrites. Safe to re-run.
180
+ function applyAppMakerMcpConfigOverrides() {
181
+ const cfgPath = path.join(HOME, '.claude', 'browser-agent-configs', 'twitter-harness-mcp.json');
182
+ if (!fs.existsSync(cfgPath)) {
183
+ console.log(` AppMaker MCP override: ${cfgPath} not found, skipping (will be picked up next run)`);
184
+ return;
185
+ }
186
+ let cfg;
187
+ try {
188
+ cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
189
+ } catch (e) {
190
+ console.warn(` AppMaker MCP override: failed to parse ${cfgPath}: ${e.message}`);
191
+ return;
192
+ }
193
+ const srv = cfg?.mcpServers?.['twitter-harness'];
194
+ if (!srv) {
195
+ console.warn(` AppMaker MCP override: ${cfgPath} has no mcpServers.twitter-harness entry`);
196
+ return;
197
+ }
198
+ if (!srv.env || typeof srv.env !== 'object') srv.env = {};
199
+ if (srv.env.BH_PORT === '9222') {
200
+ console.log(` AppMaker MCP override: BH_PORT=9222 already set in ${cfgPath}`);
201
+ return;
202
+ }
203
+ srv.env.BH_PORT = '9222';
204
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
205
+ console.log(` AppMaker MCP override: set BH_PORT=9222 in ${cfgPath}`);
206
+ }
207
+
173
208
  // Provision the browser-harness toolchain that backs the twitter-harness MCP:
174
209
  // 1. install uv (Astral) if missing
175
210
  // 2. git-clone browser-use/browser-harness
176
211
  // 3. uv tool install -e . (provides the `browser-harness` CLI)
177
212
  // 4. ensure `mcp` Python package is importable for server.py
178
213
  // 5. copy our shipped server.py into ~/.claude/mcp-servers/browser-harness/
179
- // All steps are idempotent. Skipped entirely on AppMaker VMs (the proxied
180
- // Chromium on 9222 replaces the harness).
214
+ // All steps are idempotent.
215
+ //
216
+ // AppMaker VMs: the toolchain is STILL needed (the MCP server.py is what
217
+ // Claude invokes during Phase 1's tweet scan, and it requires uv + mcp +
218
+ // browser-harness CLI to run). The AppMaker-specific deltas are:
219
+ // (a) writeAppMakerEnvFile() points TWITTER_CDP_URL at 9222 for posting
220
+ // (b) applyAppMakerMcpConfigOverrides() injects BH_PORT=9222 so server.py
221
+ // drives the AppMaker Chromium instead of trying to launch its own
222
+ // Chrome on 9555. server.py's ensure_chrome() short-circuits when CDP
223
+ // is already alive on PORT, so no double-Chrome ever spawns.
224
+ // Previously we early-returned here on AppMaker, which left the VM without
225
+ // uv installed and broke Phase 1's Claude scan (the MCP server's `command:
226
+ // /root/.local/bin/uv` resolved to ENOENT, Claude got no tools, returned an
227
+ // empty envelope).
181
228
  function installBrowserHarness() {
182
- if (isAppMakerVm()) {
183
- console.log(' AppMaker VM detected -> skipping browser-harness install (Chromium on 9222 is canonical)');
229
+ const onAppMaker = isAppMakerVm();
230
+ if (onAppMaker) {
231
+ console.log(' AppMaker VM detected -> installing harness toolchain (deps); MCP will be pointed at port 9222');
184
232
  writeAppMakerEnvFile();
185
- return;
233
+ // scripts/run_claude.sh uses `uuidgen` for session IDs on AUP-retry. The
234
+ // base image ships libuuid1 (shared lib) but not the CLI tool — the
235
+ // package is `uuid-runtime`. Without it, run_claude.sh's session_id
236
+ // generation falls back to empty string and claude --session-id breaks.
237
+ console.log(' installing uuid-runtime (uuidgen) for run_claude.sh...');
238
+ spawnSync('bash', ['-lc', 'command -v uuidgen >/dev/null 2>&1 || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq uuid-runtime'], { stdio: 'inherit' });
186
239
  }
187
240
  console.log(' setting up browser-harness (twitter-harness MCP backend)...');
188
241
 
@@ -440,6 +493,9 @@ function init() {
440
493
  installBrowserHarness();
441
494
  // Install browser agent MCP configs + profile dirs (skips existing files)
442
495
  installBrowserAgentConfigs();
496
+ // On AppMaker VMs, patch the twitter-harness MCP config so its server.py
497
+ // drives port 9222 (AppMaker Chromium) instead of the default 9555.
498
+ if (isAppMakerVm()) applyAppMakerMcpConfigOverrides();
443
499
  // Register those MCP servers with Claude so they show up in `claude mcp list`.
444
500
  registerBrowserAgentMcpServers();
445
501
 
@@ -524,6 +580,9 @@ function update() {
524
580
  installBrowserHarness();
525
581
  // Top up browser agent configs (won't overwrite user customizations)
526
582
  installBrowserAgentConfigs();
583
+ // On AppMaker VMs, patch the twitter-harness MCP config so its server.py
584
+ // drives port 9222 (AppMaker Chromium) instead of the default 9555.
585
+ if (isAppMakerVm()) applyAppMakerMcpConfigOverrides();
527
586
  // Register any newly added MCP servers with Claude (idempotent).
528
587
  registerBrowserAgentMcpServers();
529
588
 
package/bin/server.js CHANGED
@@ -994,6 +994,74 @@ async function enrichEngageRuns(runs) {
994
994
  }
995
995
  }
996
996
 
997
+ // dm_outreach_* runs: skill/dm-outreach-*.sh always calls log_run.py with
998
+ // hardcoded --posted 0 --skipped 0 --failed 0 because per-DM outcomes go to
999
+ // the `dms` table via dm_db_update.py, not the log summary. Without an
1000
+ // enricher the dashboard literally renders "posted 0 / skipped 0" even on
1001
+ // runs that sent multiple DMs (and the operator wrongly concludes the cron
1002
+ // is broken). Mirror enrichEngageRuns: derive attempted / sent / skipped /
1003
+ // errored from `dms` rows whose claude_session_id belongs to this run's
1004
+ // window. Cost is preserved as-is (get_run_cost.py already aggregates the
1005
+ // whole job, identical pattern to engage / post-comments runs).
1006
+ async function enrichDMOutreachRuns(runs) {
1007
+ const dmRuns = runs.filter(r => r.job_type === 'dm-outreach' && r.platform_key);
1008
+ if (!dmRuns.length) return;
1009
+ let oldestMs = Infinity;
1010
+ for (const r of dmRuns) {
1011
+ const ms = new Date(r.started_at).getTime();
1012
+ if (ms < oldestMs) oldestMs = ms;
1013
+ }
1014
+ // Slack window: the shell wrapper sets CLAUDE_SESSION_ID up front but
1015
+ // claude_sessions row gets stamped when the headless run starts (a few
1016
+ // seconds in); skip rows can fire >5 min before sent rows in the same
1017
+ // session. 5 min slack matches what enrichRunsCostBreakdown uses.
1018
+ const slackMs = 5 * 60 * 1000;
1019
+ const since = new Date(oldestMs - slackMs).toISOString();
1020
+ // Join dms -> claude_sessions so each dms row carries the session's
1021
+ // started_at (used to assign it to the right run row when multiple
1022
+ // dm-outreach runs land in the same query window).
1023
+ const rows = await pq(
1024
+ "SELECT d.platform, d.status, cs.started_at, cs.script " +
1025
+ "FROM dms d JOIN claude_sessions cs ON cs.session_id = d.claude_session_id " +
1026
+ "WHERE cs.started_at >= $1::timestamp AND cs.script LIKE 'dm-outreach-%'",
1027
+ [since]
1028
+ );
1029
+ if (!rows) return;
1030
+ const pendingByPlatform = {};
1031
+ const pendingRows = await pq(
1032
+ "SELECT platform, COUNT(*)::int AS n FROM dms WHERE status='pending' GROUP BY platform"
1033
+ );
1034
+ if (pendingRows) {
1035
+ for (const r of pendingRows) pendingByPlatform[(r.platform || '').toLowerCase()] = r.n;
1036
+ }
1037
+ for (const run of dmRuns) {
1038
+ const startMs = new Date(run.started_at).getTime();
1039
+ const endMs = new Date(run.finished_at).getTime() + slackMs;
1040
+ // dm_outreach_twitter run -> DB platform 'x'. Reddit/LinkedIn keep slug.
1041
+ const dbPlatform = run.platform_key === 'twitter' ? 'x' : run.platform_key;
1042
+ let sent = 0, skipped = 0, errored = 0, attempted = 0;
1043
+ for (const p of rows) {
1044
+ if ((p.platform || '').toLowerCase() !== dbPlatform) continue;
1045
+ const sessTs = p.started_at instanceof Date ? p.started_at.getTime() : Date.parse(p.started_at);
1046
+ if (sessTs < startMs - slackMs || sessTs > endMs) continue;
1047
+ attempted++;
1048
+ if (p.status === 'sent') sent++;
1049
+ else if (p.status === 'skipped') skipped++;
1050
+ else if (p.status === 'error') errored++;
1051
+ }
1052
+ const prior = run.result || {};
1053
+ run.result = {
1054
+ type: 'dm-outreach',
1055
+ attempted,
1056
+ sent,
1057
+ skipped,
1058
+ errored,
1059
+ pending_now: pendingByPlatform[dbPlatform] || 0,
1060
+ cost_usd: prior.cost_usd || 0,
1061
+ };
1062
+ }
1063
+ }
1064
+
997
1065
  // scan_*_replies / scan_*_followups / scan_*_mentions runs: the shell wrappers
998
1066
  // log `posted=FOUND` where FOUND is grepped from stdout, which is fragile and
999
1067
  // zero-by-default. Replace it with a direct count of rows inserted into the
@@ -4237,6 +4305,7 @@ async function handleApi(req, res) {
4237
4305
  }
4238
4306
  await enrichLinkEditRuns(runs);
4239
4307
  await enrichEngageRuns(runs);
4308
+ await enrichDMOutreachRuns(runs);
4240
4309
  await enrichCheckRepliesRuns(runs);
4241
4310
  await enrichPostCommentsLinkedInRuns(runs);
4242
4311
  await enrichPostCommentsTwitterRuns(runs);
@@ -6783,6 +6852,9 @@ async function handleApi(req, res) {
6783
6852
  linkedin: p => !isDisabled(p, 'linkedin') && hasSearchTopics(p),
6784
6853
  reddit: p => !isDisabled(p, 'reddit'),
6785
6854
  moltbook: p => !isDisabled(p, 'moltbook'),
6855
+ // Instagram doesn't use search_topics; posts come from prerendered mixer
6856
+ // drafts. Projects can still opt out via platforms_disabled.
6857
+ instagram: p => !isDisabled(p, 'instagram'),
6786
6858
  };
6787
6859
  const totalWeightByPlatform = {};
6788
6860
  for (const plat of platforms) {
@@ -9487,6 +9559,30 @@ function renderResult(run) {
9487
9559
  '</span>'
9488
9560
  );
9489
9561
  }
9562
+ if (r.type === 'dm-outreach') {
9563
+ // Per-run DB-derived counts (see enrichDMOutreachRuns in server.js).
9564
+ // dm-outreach-*.sh hardcodes posted/skipped/failed=0 in log_run.py
9565
+ // because per-DM outcomes go to the dms table via dm_db_update.py.
9566
+ // Pills here come from joining dms -> claude_sessions on the run window.
9567
+ // attempted is the total candidates the run actually resolved (sent +
9568
+ // skipped + errored); the operator wants to see "we tried 7, sent 2"
9569
+ // not "0". (No backticks in this comment block: renderResult lives
9570
+ // inside an outer HTML template literal, see note at the engage branch.)
9571
+ const attempted = r.attempted || 0;
9572
+ const sent = r.sent || 0;
9573
+ const skipped = r.skipped || 0;
9574
+ const errored = r.errored || 0;
9575
+ const pending = r.pending_now || 0;
9576
+ const cost = r.cost_usd || 0;
9577
+ return (
9578
+ pill('attempted', attempted, attempted > 0 ? 'var(--text)' : 'var(--muted)') +
9579
+ pill('sent', sent, sent > 0 ? '#22c55e' : 'var(--muted)') +
9580
+ pill('skipped', skipped, skipped > 0 ? '#eab308' : 'var(--muted)') +
9581
+ pill('errored', errored, errored > 0 ? '#ef4444' : 'var(--muted)') +
9582
+ pill('queue', pending, pending > 0 ? 'var(--text)' : 'var(--muted)') +
9583
+ '<span style="font-size:12px;color:var(--muted);">$' + cost.toFixed(2) + '</span>'
9584
+ );
9585
+ }
9490
9586
  if (r.type === 'engage') {
9491
9587
  // Per-run DB-derived counts (see enrichEngageRuns in server.js).
9492
9588
  // Empty-queue runs show "queue empty" so the operator can tell a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.6",
3
+ "version": "1.6.7",
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"
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "bin/",
15
15
  "scripts/*.py",
16
+ "scripts/*.sh",
16
17
  "scripts/engagement_styles_extra.json",
17
18
  "schema-postgres.sql",
18
19
  "config.example.json",
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ # Usage: _dm_icp_batch.sh DM_ID DEFAULT_LABEL DEFAULT_NOTES [override:project=label:notes ...]
3
+ # Sets default label/notes on all 19 projects, then applies overrides
4
+ set -e
5
+ DM_ID=$1
6
+ DEFAULT_LABEL=$2
7
+ DEFAULT_NOTES=$3
8
+ shift 3
9
+
10
+ declare -A LABELS NOTES
11
+ PROJECTS=(fazm Terminator "macOS MCP" Vipassana S4L "AI Browser Profile" "WhatsApp MCP" "macOS Session Replay" Cyrano Assrt PieLine Clone mk0r fde10x claude-meter c0nsl tenxats paperback-expert studyly)
12
+ for p in "${PROJECTS[@]}"; do
13
+ LABELS["$p"]="$DEFAULT_LABEL"
14
+ NOTES["$p"]="$DEFAULT_NOTES"
15
+ done
16
+
17
+ for arg in "$@"; do
18
+ proj="${arg%%=*}"
19
+ rest="${arg#*=}"
20
+ lbl="${rest%%:*}"
21
+ note="${rest#*:}"
22
+ LABELS["$proj"]="$lbl"
23
+ NOTES["$proj"]="$note"
24
+ done
25
+
26
+ cd /Users/matthewdi/social-autoposter
27
+ for p in "${PROJECTS[@]}"; do
28
+ python3 scripts/dm_conversation.py set-icp-precheck \
29
+ --dm-id "$DM_ID" --project "$p" \
30
+ --label "${LABELS[$p]}" --notes "${NOTES[$p]}" >/dev/null
31
+ done
32
+ echo "ICP prechecks set for DM $DM_ID (default=$DEFAULT_LABEL, ${#@} overrides)"
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: ./_dm_record_sent.sh DM_ID "content"
3
+ set -e
4
+ DM=$1
5
+ CONTENT=$2
6
+ source ~/social-autoposter/.env
7
+ psql "$DATABASE_URL" -c "UPDATE dms SET status='sent', our_dm_content=\$\$$CONTENT\$\$, sent_at=NOW(), claude_session_id='967dfa68-2d39-42de-99e9-acdb2d3552ac'::uuid WHERE id=$DM;" 2>&1 | tail -1
8
+ python3 /Users/matthewdi/social-autoposter/scripts/dm_conversation.py log-outbound --dm-id "$DM" --content "$CONTENT" --verified 2>&1 | tail -1
@@ -49,6 +49,73 @@ PLATFORM_ALIAS = {
49
49
  }
50
50
 
51
51
 
52
+ # Per-process cache for active campaign suffixes; populated lazily on first
53
+ # format_block() call. None = not loaded yet; [] = loaded but empty.
54
+ _ACTIVE_CAMPAIGN_SUFFIXES_CACHE = None
55
+
56
+
57
+ def _load_active_campaign_suffixes():
58
+ """Best-effort: return a list of currently-active campaign suffix literals.
59
+
60
+ Mirrors the helper of the same name in scripts/top_performers.py. We
61
+ duplicate (rather than import) to keep this module's failure mode
62
+ independent of top_performers' larger dependency surface.
63
+
64
+ Used to strip the suffix from `our_content` before injecting prior
65
+ interactions into the draft prompt, so the LLM never learns to echo
66
+ the suffix in its drafts (which would then double-fire when the
67
+ tool-layer injection at twitter_browser.reply_to_tweet / reddit_browser
68
+ appends a second copy). See feedback_suffix_injection_gating.md for the
69
+ history; this closes the 4th leak path that the 2026-05-19 sweep missed.
70
+
71
+ On any failure returns []: missing strip is preferable to crashing the
72
+ prompt assembly path.
73
+ """
74
+ global _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
75
+ if _ACTIVE_CAMPAIGN_SUFFIXES_CACHE is not None:
76
+ return _ACTIVE_CAMPAIGN_SUFFIXES_CACHE
77
+ suffixes = []
78
+ try:
79
+ from http_api import api_get # noqa: E402
80
+ resp = api_get(
81
+ "/api/v1/campaigns",
82
+ query={"status": "active", "has_suffix": "true", "limit": 500},
83
+ )
84
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
85
+ for r in rows:
86
+ s = (r.get("suffix") or "").strip()
87
+ if s and s not in suffixes:
88
+ suffixes.append(s)
89
+ except Exception as e:
90
+ print(
91
+ f"[author_history_block] _load_active_campaign_suffixes failed: {e!r}",
92
+ file=sys.stderr,
93
+ )
94
+ _ACTIVE_CAMPAIGN_SUFFIXES_CACHE = suffixes
95
+ return suffixes
96
+
97
+
98
+ def _strip_active_campaign_suffixes(text, suffixes):
99
+ """Trailing-only, idempotent strip of any active-campaign suffix.
100
+
101
+ Identical contract to top_performers._strip_active_campaign_suffixes.
102
+ Idempotent loop also collapses an already-doubled historical suffix
103
+ (e.g. "... written with s4lai written with s4lai") to clean text.
104
+ Trailing-only so we never touch the body of the comment.
105
+ """
106
+ if not text or not suffixes:
107
+ return text
108
+ cleaned = text.rstrip()
109
+ changed = True
110
+ while changed:
111
+ changed = False
112
+ for sfx in suffixes:
113
+ if sfx and cleaned.endswith(sfx):
114
+ cleaned = cleaned[: -len(sfx)].rstrip()
115
+ changed = True
116
+ return cleaned
117
+
118
+
52
119
  def _normalize(handle):
53
120
  """Lowercase + strip @, u/, / prefixes. Empty/'unknown' → empty string."""
54
121
  if not handle:
@@ -188,6 +255,12 @@ def format_block(rows, author, platform, days):
188
255
  f"window={days}d, latest first):"
189
256
  )
190
257
  lines = [header]
258
+ # Load active campaign suffixes ONCE per format_block call so we strip
259
+ # them off `our_content` BEFORE truncation. Short Twitter replies
260
+ # (≤140 chars total) would otherwise show the suffix verbatim in the
261
+ # exemplar, training the LLM to echo it; the tool layer then appends
262
+ # a second copy. See feedback_suffix_injection_gating.md.
263
+ suffix_strip_list = _load_active_campaign_suffixes()
191
264
  for row in rows:
192
265
  (
193
266
  _id,
@@ -202,7 +275,10 @@ def format_block(rows, author, platform, days):
202
275
  ) = row
203
276
  date = posted_at.date().isoformat() if posted_at else "?"
204
277
  proj = project or "?"
205
- ours = _truncate(our_content, 140)
278
+ our_content_clean = _strip_active_campaign_suffixes(
279
+ our_content, suffix_strip_list
280
+ )
281
+ ours = _truncate(our_content_clean, 140)
206
282
  eng_bits = []
207
283
  if upvotes:
208
284
  eng_bits.append(f"likes={upvotes}")
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ # bench_dashboard.sh
3
+ #
4
+ # Benchmark Social Autoposter dashboard endpoint latencies using curl + awk.
5
+ # Prints a table with p50/p95/min/max per endpoint.
6
+ #
7
+ # Env vars:
8
+ # BASE_URL default http://localhost:3141
9
+ # RUNS default 10 (requests per endpoint)
10
+ # CONCURRENCY default 1 (serial). If >1, uses background curls + wait.
11
+ #
12
+ # Always exits 0.
13
+
14
+ set -u
15
+
16
+ BASE_URL="${BASE_URL:-http://localhost:3141}"
17
+ RUNS="${RUNS:-10}"
18
+ CONCURRENCY="${CONCURRENCY:-1}"
19
+
20
+ ENDPOINTS=(
21
+ "/"
22
+ "/api/pending"
23
+ "/api/activity/stats?hours=24"
24
+ "/api/style/stats?hours=24"
25
+ "/api/status"
26
+ "/api/jobs"
27
+ )
28
+
29
+ TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"
30
+
31
+ printf 'bench_dashboard.sh time=%s base=%s runs=%s concurrency=%s\n' \
32
+ "$TIMESTAMP" "$BASE_URL" "$RUNS" "$CONCURRENCY"
33
+ printf '\n'
34
+
35
+ # Header row
36
+ printf '%-40s %-3s %-7s %-7s %-7s %-7s %s\n' \
37
+ "endpoint" "n" "p50" "p95" "min" "max" "codes"
38
+
39
+ TMPDIR="$(mktemp -d -t bench_dashboard.XXXXXX)"
40
+ trap 'rm -rf "$TMPDIR"' EXIT
41
+
42
+ # Run one curl, append "http_code time_total" to the given outfile.
43
+ # Args: URL OUTFILE
44
+ run_one() {
45
+ local url="$1"
46
+ local out="$2"
47
+ # -s silent, -o /dev/null discard body, -w format.
48
+ # On connection failure curl prints "000 0.000".
49
+ local line
50
+ line="$(curl -s -o /dev/null -w '%{http_code} %{time_total}\n' \
51
+ --max-time 60 "$url" 2>/dev/null || true)"
52
+ if [ -z "$line" ]; then
53
+ line="000 0.000"
54
+ fi
55
+ printf '%s\n' "$line" >> "$out"
56
+ }
57
+
58
+ for ep in "${ENDPOINTS[@]}"; do
59
+ url="${BASE_URL}${ep}"
60
+ outfile="${TMPDIR}/out.$$.$(echo "$ep" | tr -c 'A-Za-z0-9' '_')"
61
+ : > "$outfile"
62
+
63
+ if [ "$CONCURRENCY" -le 1 ]; then
64
+ i=0
65
+ while [ "$i" -lt "$RUNS" ]; do
66
+ run_one "$url" "$outfile"
67
+ i=$((i + 1))
68
+ done
69
+ else
70
+ # Launch in waves of CONCURRENCY until RUNS total are done.
71
+ launched=0
72
+ while [ "$launched" -lt "$RUNS" ]; do
73
+ wave=0
74
+ pids=""
75
+ while [ "$wave" -lt "$CONCURRENCY" ] && [ "$launched" -lt "$RUNS" ]; do
76
+ run_one "$url" "$outfile" &
77
+ pids="$pids $!"
78
+ wave=$((wave + 1))
79
+ launched=$((launched + 1))
80
+ done
81
+ # wait for this wave
82
+ for p in $pids; do
83
+ wait "$p" 2>/dev/null || true
84
+ done
85
+ done
86
+ fi
87
+
88
+ # Compute stats with awk.
89
+ # Input: lines of "CODE TIME". Output one line:
90
+ # count p50 p95 min max codes
91
+ awk -v ep="$ep" '
92
+ {
93
+ code = $1
94
+ t = $2 + 0
95
+ times[NR] = t
96
+ codes[code]++
97
+ n++
98
+ if (n == 1 || t < mn) mn = t
99
+ if (n == 1 || t > mx) mx = t
100
+ }
101
+ END {
102
+ if (n == 0) {
103
+ printf "%-40s %-3d %-7s %-7s %-7s %-7s %s\n", ep, 0, "-", "-", "-", "-", "none"
104
+ exit
105
+ }
106
+ # sort times ascending (insertion sort, fine for small n)
107
+ for (i = 2; i <= n; i++) {
108
+ v = times[i]; j = i - 1
109
+ while (j >= 1 && times[j] > v) { times[j+1] = times[j]; j-- }
110
+ times[j+1] = v
111
+ }
112
+ # p50 and p95 using nearest-rank, 1-indexed
113
+ p50_idx = int((50/100) * n + 0.9999); if (p50_idx < 1) p50_idx = 1; if (p50_idx > n) p50_idx = n
114
+ p95_idx = int((95/100) * n + 0.9999); if (p95_idx < 1) p95_idx = 1; if (p95_idx > n) p95_idx = n
115
+ p50 = times[p50_idx]
116
+ p95 = times[p95_idx]
117
+
118
+ # Build codes string sorted by code key
119
+ ncodes = 0
120
+ for (c in codes) { ncodes++; ck[ncodes] = c }
121
+ for (i = 2; i <= ncodes; i++) {
122
+ v = ck[i]; j = i - 1
123
+ while (j >= 1 && ck[j] > v) { ck[j+1] = ck[j]; j-- }
124
+ ck[j+1] = v
125
+ }
126
+ codes_str = ""
127
+ for (i = 1; i <= ncodes; i++) {
128
+ sep = (i == 1) ? "" : " "
129
+ codes_str = codes_str sep ck[i] "x" codes[ck[i]]
130
+ }
131
+
132
+ printf "%-40s %-3d %-7.3f %-7.3f %-7.3f %-7.3f %s\n", \
133
+ ep, n, p50, p95, mn, mx, codes_str
134
+ }
135
+ ' "$outfile"
136
+ done
137
+
138
+ exit 0
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # clean_stale_singleton.sh — Remove stale Chrome singleton symlinks from a
3
+ # browser profile if (and only if) the PID they reference is dead.
4
+ #
5
+ # Background: when Chrome exits ungracefully (SIGKILL, system sleep, force
6
+ # quit, jetsam), it leaves Singleton{Lock,Cookie,Socket} + RunningChromeVersion
7
+ # symlinks behind. On the next launch Chrome sees them, fails to talk to the
8
+ # (now-dead) PID listed in SingletonLock, and pops "Something went wrong when
9
+ # opening your profile. Some features may be unavailable" once per service
10
+ # (cookies, prefs, history, sync, ...) — typically 7 dialogs. Until the user
11
+ # clicks all of them, no pages load and the pipeline hangs.
12
+ #
13
+ # Safe to call before any Chrome launch on the same profile. Idempotent.
14
+ # Refuses to clean if the SingletonLock PID is still alive (so we never
15
+ # yank locks out from under a running Chrome — including a real user
16
+ # session attached to the same profile).
17
+ #
18
+ # Usage: clean_stale_singleton.sh <profile_dir>
19
+ # e.g. clean_stale_singleton.sh ~/.claude/browser-profiles/twitter
20
+
21
+ set -uo pipefail
22
+
23
+ profile_dir="${1:-}"
24
+ if [ -z "$profile_dir" ] || [ ! -d "$profile_dir" ]; then
25
+ echo "[clean_stale_singleton] usage: $0 <profile_dir>" >&2
26
+ exit 0 # never block the pipeline on a misuse; just no-op
27
+ fi
28
+
29
+ lock_link="$profile_dir/SingletonLock"
30
+
31
+ # No lock = nothing to clean.
32
+ if [ ! -L "$lock_link" ] && [ ! -e "$lock_link" ]; then
33
+ exit 0
34
+ fi
35
+
36
+ # SingletonLock target format: <hostname>-<pid>
37
+ target=$(readlink "$lock_link" 2>/dev/null || echo "")
38
+ pid="${target##*-}"
39
+
40
+ if [ -n "$pid" ] && [[ "$pid" =~ ^[0-9]+$ ]]; then
41
+ if kill -0 "$pid" 2>/dev/null; then
42
+ # Live Chrome owns this profile. Do NOT touch.
43
+ echo "[clean_stale_singleton] ${profile_dir##*/}: SingletonLock PID $pid alive; leaving locks intact." >&2
44
+ exit 0
45
+ fi
46
+ fi
47
+
48
+ # Stale: PID dead, malformed, or unreadable. Nuke the singletons so Chrome
49
+ # can launch cleanly. Also drop RunningChromeVersion which Chrome cross-checks.
50
+ rm -f "$profile_dir/SingletonLock" \
51
+ "$profile_dir/SingletonCookie" \
52
+ "$profile_dir/SingletonSocket" \
53
+ "$profile_dir/RunningChromeVersion"
54
+
55
+ echo "[clean_stale_singleton] ${profile_dir##*/}: cleared stale singleton locks (was PID ${pid:-unknown})." >&2
56
+ exit 0