social-autoposter 1.6.81 → 1.6.83
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 +111 -2
- package/mcp/dist/runtime.js +32 -0
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +72 -13
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/post_reddit.py +38 -11
- package/scripts/seed_search_queries.py +18 -1
- package/scripts/sentry_init.py +37 -0
- package/scripts/twitter_post_plan.py +108 -25
- package/skill/run-twitter-cycle.sh +1 -1
package/mcp/dist/index.js
CHANGED
|
@@ -1424,6 +1424,96 @@ async function autopilotLoaded() {
|
|
|
1424
1424
|
}
|
|
1425
1425
|
return { autopilot_on, auto_update_on };
|
|
1426
1426
|
}
|
|
1427
|
+
// ---- Autopilot prompt: keep the scheduled task's SKILL.md current ------------
|
|
1428
|
+
// The scheduled task's prompt (SKILL.md) is owned by the host and does NOT update
|
|
1429
|
+
// when the plugin updates. So a behavior change (the no-improvise / voice-inline /
|
|
1430
|
+
// stop-cleanly rules) would never reach an already-scheduled task. We close that
|
|
1431
|
+
// gap by REWRITING the task's SKILL.md on server boot when its embedded prompt
|
|
1432
|
+
// version is older than what this build ships. The host reads the file fresh on
|
|
1433
|
+
// each run, so the next fire picks up the new prompt — no host tool, no user
|
|
1434
|
+
// action. Bump AUTOPILOT_PROMPT_VERSION whenever the prompt below changes.
|
|
1435
|
+
const AUTOPILOT_PROMPT_VERSION = 1;
|
|
1436
|
+
const AUTOPILOT_PROMPT_MARKER = "saps_autopilot_prompt_version";
|
|
1437
|
+
function autopilotSkillPath() {
|
|
1438
|
+
const cfg = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
1439
|
+
return path.join(cfg, "scheduled-tasks", AUTOPILOT_TASK_ID, "SKILL.md");
|
|
1440
|
+
}
|
|
1441
|
+
// The canonical draft-only autopilot prompt: uses ONLY scan_candidates +
|
|
1442
|
+
// submit_drafts, drafts from the INLINE project_voices, improvises nothing, and
|
|
1443
|
+
// stops cleanly if its tools aren't available.
|
|
1444
|
+
function autopilotSkillMd(project) {
|
|
1445
|
+
const body = [
|
|
1446
|
+
`You are running the Social Autoposter draft-only autopilot for the project "${project}". ` +
|
|
1447
|
+
`Run ONE cycle, then stop. DRAFT-ONLY: you must NEVER post to X/Twitter — you only queue ` +
|
|
1448
|
+
`drafts for the user's approval. Use ONLY two tools — scan_candidates and submit_drafts — ` +
|
|
1449
|
+
`and IMPROVISE NOTHING ELSE.`,
|
|
1450
|
+
``,
|
|
1451
|
+
`Steps:`,
|
|
1452
|
+
`1. Call scan_candidates with project "${project}". It long-polls: if it returns a "Scan in ` +
|
|
1453
|
+
`progress" status, call scan_candidates again (same args) and keep re-calling until it ` +
|
|
1454
|
+
`returns candidates. Never sleep or use background waits. If it returns no candidates, stop ` +
|
|
1455
|
+
`cleanly — nothing to do this cycle.`,
|
|
1456
|
+
`2. The result includes a project_voices field — the brand voice + guardrails for each ` +
|
|
1457
|
+
`candidate's project. Draft from project_voices[<matched_project>] (voice, description, ` +
|
|
1458
|
+
`differentiator, content_guardrails). NEVER read config files, call project_config, or run ` +
|
|
1459
|
+
`Bash/Read to find the voice — everything you need is already in the scan result.`,
|
|
1460
|
+
`3. For each candidate you judge genuinely worth engaging, write ONE on-brand reply (<=250 ` +
|
|
1461
|
+
`chars, match the thread's language, genuinely helpful — not spammy, no hard selling). Skip ` +
|
|
1462
|
+
`the rest.`,
|
|
1463
|
+
`4. Call submit_drafts with the batch_id from scan_candidates and a drafts array of ` +
|
|
1464
|
+
`{candidate_id, reply_text}. This queues them for the menu-bar approval UI. Do NOT call ` +
|
|
1465
|
+
`post_drafts — posting is the user's decision.`,
|
|
1466
|
+
``,
|
|
1467
|
+
`HARD GUARD: if scan_candidates is NOT available (ToolSearch returns no matching tool — can ` +
|
|
1468
|
+
`happen when a run spawns before the extension's MCP server has reconnected), STOP immediately ` +
|
|
1469
|
+
`and report "S4L tools unavailable, skipping this cycle." Do NOT search the connector registry, ` +
|
|
1470
|
+
`do NOT call list_connectors, do NOT improvise any other tool.`,
|
|
1471
|
+
].join("\n");
|
|
1472
|
+
return (`---\n` +
|
|
1473
|
+
`name: ${AUTOPILOT_TASK_ID}\n` +
|
|
1474
|
+
`description: Social Autoposter draft-only autopilot for project ${project} — scans X, drafts ` +
|
|
1475
|
+
`on-brand replies (voice inline), queues them for approval. Never posts.\n` +
|
|
1476
|
+
`---\n\n` +
|
|
1477
|
+
body +
|
|
1478
|
+
`\n\n<!-- ${AUTOPILOT_PROMPT_MARKER}: ${AUTOPILOT_PROMPT_VERSION} -->\n`);
|
|
1479
|
+
}
|
|
1480
|
+
// Read the project name back from an existing autopilot SKILL.md so a rewrite
|
|
1481
|
+
// keeps the same project. Null if it can't be determined (then leave it alone).
|
|
1482
|
+
function detectAutopilotProject(skillMd) {
|
|
1483
|
+
const m1 = /project[s]?\s+"([^"]+)"/i.exec(skillMd);
|
|
1484
|
+
if (m1)
|
|
1485
|
+
return m1[1];
|
|
1486
|
+
const m2 = /for project\s+([A-Za-z0-9_-]+)/i.exec(skillMd);
|
|
1487
|
+
if (m2)
|
|
1488
|
+
return m2[1];
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
// Refresh the scheduled task's SKILL.md when this build ships a newer prompt than
|
|
1492
|
+
// what's on disk. Best-effort, synchronous, never throws. Only touches an EXISTING
|
|
1493
|
+
// task (onboarding creates the first one), only when stale, and only when the
|
|
1494
|
+
// project reads back, so we never write a wrong/blank project.
|
|
1495
|
+
function ensureAutopilotPromptCurrent() {
|
|
1496
|
+
try {
|
|
1497
|
+
const skillPath = autopilotSkillPath();
|
|
1498
|
+
if (!fs.existsSync(skillPath))
|
|
1499
|
+
return; // no scheduled task yet
|
|
1500
|
+
const cur = fs.readFileSync(skillPath, "utf-8");
|
|
1501
|
+
const m = new RegExp(`${AUTOPILOT_PROMPT_MARKER}:\\s*(\\d+)`).exec(cur);
|
|
1502
|
+
const curVer = m ? parseInt(m[1], 10) : 0;
|
|
1503
|
+
if (curVer >= AUTOPILOT_PROMPT_VERSION)
|
|
1504
|
+
return; // already current
|
|
1505
|
+
const project = detectAutopilotProject(cur);
|
|
1506
|
+
if (!project) {
|
|
1507
|
+
console.error(`[autopilot] prompt is stale (v${curVer}) but project unreadable from SKILL.md; leaving it`);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
fs.writeFileSync(skillPath, autopilotSkillMd(project), "utf-8");
|
|
1511
|
+
console.error(`[autopilot] refreshed scheduled-task prompt -> v${AUTOPILOT_PROMPT_VERSION} (was v${curVer}), project=${project}`);
|
|
1512
|
+
}
|
|
1513
|
+
catch (e) {
|
|
1514
|
+
console.error(`[autopilot] ensureAutopilotPromptCurrent error: ${e?.message || e}`);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1427
1517
|
// Assemble everything the panel needs in one shot (projects + X + autopilot +
|
|
1428
1518
|
// version). Resilient: any probe that throws degrades to a safe default rather
|
|
1429
1519
|
// than failing the whole snapshot.
|
|
@@ -2261,6 +2351,11 @@ async function main() {
|
|
|
2261
2351
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
2262
2352
|
// the version first materialized at install). Synchronous + best-effort.
|
|
2263
2353
|
ensurePipelineCurrent();
|
|
2354
|
+
// Same problem for the scheduled task's prompt: the host owns its SKILL.md and
|
|
2355
|
+
// a plugin update never touches it. Rewrite it on boot when this build ships a
|
|
2356
|
+
// newer prompt version, so behavior changes (no-improvise / voice-inline /
|
|
2357
|
+
// stop-cleanly) reach an already-scheduled task on its next fire. Best-effort.
|
|
2358
|
+
ensureAutopilotPromptCurrent();
|
|
2264
2359
|
const transport = new StdioServerTransport();
|
|
2265
2360
|
await server.connect(transport);
|
|
2266
2361
|
console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
|
|
@@ -2274,8 +2369,22 @@ async function main() {
|
|
|
2274
2369
|
// and cheap when already present, so existing installs pick it up on the next
|
|
2275
2370
|
// Claude restart without re-provisioning. Best-effort: never blocks boot.
|
|
2276
2371
|
void ensureMenubar()
|
|
2277
|
-
.then((r) =>
|
|
2278
|
-
|
|
2372
|
+
.then((r) => {
|
|
2373
|
+
console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`);
|
|
2374
|
+
// A non-skipped failure here is the boot-time "menu bar didn't come up"
|
|
2375
|
+
// path (e.g. uv missing, rumps reinstall failed on an existing install).
|
|
2376
|
+
// Report it; a skip (non-macOS / runtime not ready) is expected, not an error.
|
|
2377
|
+
if (!r.ok && !r.skipped) {
|
|
2378
|
+
captureError(new Error(`menubar ensure failed: ${r.detail}`), {
|
|
2379
|
+
component: "menubar",
|
|
2380
|
+
phase: "ensure",
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
})
|
|
2384
|
+
.catch((e) => {
|
|
2385
|
+
console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e);
|
|
2386
|
+
captureError(e, { component: "menubar", phase: "ensure" });
|
|
2387
|
+
});
|
|
2279
2388
|
// Phone home so this .mcpb install is visible in the install-lane digest
|
|
2280
2389
|
// (parity with the npx launchd heartbeat). Once on startup, then every 15m
|
|
2281
2390
|
// while the desktop app keeps the server alive. unref() so it never holds the
|
package/mcp/dist/runtime.js
CHANGED
|
@@ -22,6 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import os from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { captureError } from "./telemetry.js";
|
|
25
26
|
// Pin the standalone CPython series the venv is built from. Bump deliberately.
|
|
26
27
|
const PYTHON_VERSION = "3.12";
|
|
27
28
|
// The CDP scan engine the twitter cycle shells out to (~/.local/bin/browser-harness).
|
|
@@ -378,6 +379,15 @@ async function provision(progress) {
|
|
|
378
379
|
progress.ok = false;
|
|
379
380
|
progress.error = msg;
|
|
380
381
|
writeProgress(progress);
|
|
382
|
+
// Every fatal install-step failure (repo unpack, uv, python, venv, deps,
|
|
383
|
+
// chromium, harness, chrome) was previously only written to the local
|
|
384
|
+
// install-progress.json, invisible to us. Report it so a failed runtime
|
|
385
|
+
// install becomes a real Sentry event, tagged with the step that failed.
|
|
386
|
+
const failedStep = progress.steps.find((s) => s.status === "running");
|
|
387
|
+
captureError(new Error(msg), {
|
|
388
|
+
component: "install",
|
|
389
|
+
...(failedStep ? { step: failedStep.id } : {}),
|
|
390
|
+
});
|
|
381
391
|
return progress;
|
|
382
392
|
};
|
|
383
393
|
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
@@ -499,6 +509,19 @@ async function provision(progress) {
|
|
|
499
509
|
if (r.code !== 0) {
|
|
500
510
|
return fail(`playwright install chromium failed (exit ${r.code}). ${r.out.slice(-400)}`);
|
|
501
511
|
}
|
|
512
|
+
// Smoke-test the EXACT gate the pipeline's post path runs at use time
|
|
513
|
+
// (twitter_post_plan.py preflight): the owned interpreter must import
|
|
514
|
+
// playwright. The reply step is the only Playwright importer, so a deps
|
|
515
|
+
// sync that left it unimportable was invisible until the first real post
|
|
516
|
+
// died with no_reply_json in production (Karol, 2026-06-22). Fail the
|
|
517
|
+
// install LOUDLY here instead.
|
|
518
|
+
const smoke = await sh(VENV_PYTHON, ["-c", "import playwright"], {
|
|
519
|
+
timeoutMs: 60000,
|
|
520
|
+
});
|
|
521
|
+
if (smoke.code !== 0) {
|
|
522
|
+
return fail(`runtime smoke test failed: ${VENV_PYTHON} cannot import playwright ` +
|
|
523
|
+
`(exit ${smoke.code}). ${smoke.out.slice(-400)}`);
|
|
524
|
+
}
|
|
502
525
|
}
|
|
503
526
|
setStep("chromium", "done");
|
|
504
527
|
// --- Step 6: browser-harness CLI -----------------------------------------
|
|
@@ -607,6 +630,15 @@ async function provision(progress) {
|
|
|
607
630
|
if (process.platform === "darwin") {
|
|
608
631
|
const mb = await installMenubar(uv, uvEnv, VENV_PYTHON);
|
|
609
632
|
setStep("menubar", mb.ok ? "done" : "error", mb.detail);
|
|
633
|
+
// Non-fatal step, so the only prior signal of a menu bar install failure was
|
|
634
|
+
// a local install-progress.json entry (invisible to us). Report it so "menu
|
|
635
|
+
// bar didn't start" becomes a real Sentry event with the failing detail.
|
|
636
|
+
if (!mb.ok) {
|
|
637
|
+
captureError(new Error(`menubar install failed: ${mb.detail}`), {
|
|
638
|
+
component: "menubar",
|
|
639
|
+
phase: "install",
|
|
640
|
+
});
|
|
641
|
+
}
|
|
610
642
|
}
|
|
611
643
|
else {
|
|
612
644
|
setStep("menubar", "done", "skipped (macOS only)");
|
package/mcp/dist/version.json
CHANGED
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.
|
|
5
|
+
"version": "1.6.83",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
|
|
7
7
|
"long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
|
@@ -24,9 +24,58 @@ import tempfile
|
|
|
24
24
|
import threading
|
|
25
25
|
import time
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# --- Sentry bootstrap --------------------------------------------------------
|
|
28
|
+
# The menu bar runs as a standalone KeepAlive LaunchAgent off the owned venv,
|
|
29
|
+
# a separate process from the MCP server, so it was a Sentry blind spot: a crash
|
|
30
|
+
# (most often rumps missing/broken in the venv -> "menu bar didn't start") only
|
|
31
|
+
# ever landed in the local menubar.err.log. Wire it in BEFORE importing rumps so
|
|
32
|
+
# even an import-time failure of the menu bar's heaviest dependency is reported.
|
|
33
|
+
# sentry_init lives in the pipeline's scripts/ dir (SAPS_REPO_DIR is exported by
|
|
34
|
+
# the launchd plist) and sentry-sdk is in the owned venv (requirements.txt). All
|
|
35
|
+
# best-effort: a missing repo path or SDK degrades to a silent no-op.
|
|
36
|
+
_sentry = None
|
|
37
|
+
try:
|
|
38
|
+
_repo = os.environ.get("SAPS_REPO_DIR")
|
|
39
|
+
if _repo:
|
|
40
|
+
_scripts = os.path.join(_repo, "scripts")
|
|
41
|
+
if _scripts not in sys.path:
|
|
42
|
+
sys.path.insert(0, _scripts)
|
|
43
|
+
import sentry_init as _sentry # noqa: E402
|
|
44
|
+
|
|
45
|
+
_sentry.init()
|
|
46
|
+
except Exception:
|
|
47
|
+
_sentry = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _capture(err, **tags):
|
|
51
|
+
"""Report a handled menu-bar error to Sentry (component=menubar) without ever
|
|
52
|
+
raising into the caller. No-op if the Sentry bootstrap above failed."""
|
|
53
|
+
try:
|
|
54
|
+
if _sentry is not None:
|
|
55
|
+
tags.setdefault("component", "menubar")
|
|
56
|
+
_sentry.capture_exception(err, tags=tags)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _flush():
|
|
62
|
+
try:
|
|
63
|
+
if _sentry is not None:
|
|
64
|
+
_sentry.flush()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
import rumps # noqa: E402
|
|
71
|
+
except Exception as _import_err:
|
|
72
|
+
# rumps missing/broken in the owned venv is THE "menu bar didn't start" case.
|
|
73
|
+
# Report it explicitly, flush, then re-raise so launchd records the crash too.
|
|
74
|
+
_capture(_import_err, phase="import_rumps")
|
|
75
|
+
_flush()
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
import s4l_state as st # noqa: E402
|
|
30
79
|
|
|
31
80
|
CLAUDE_APP = "Claude"
|
|
32
81
|
POLL_SECONDS = 5
|
|
@@ -47,7 +96,6 @@ SETUP_PROMPT = (
|
|
|
47
96
|
"must interactively sign in or no product can be identified."
|
|
48
97
|
)
|
|
49
98
|
DRAFT_PROMPT = "Run a social-autoposter draft cycle and show me the drafts to review."
|
|
50
|
-
POST_PROMPT = "Post my approved social-autoposter drafts."
|
|
51
99
|
UPDATE_PROMPT = "Update social-autoposter to the latest version."
|
|
52
100
|
|
|
53
101
|
|
|
@@ -183,9 +231,6 @@ class S4LMenuBar(rumps.App):
|
|
|
183
231
|
def _draft(self, _=None):
|
|
184
232
|
self._send_to_claude(DRAFT_PROMPT)
|
|
185
233
|
|
|
186
|
-
def _post(self, _=None):
|
|
187
|
-
self._send_to_claude(POST_PROMPT)
|
|
188
|
-
|
|
189
234
|
def _update(self, _=None):
|
|
190
235
|
self._send_to_claude(UPDATE_PROMPT)
|
|
191
236
|
|
|
@@ -319,8 +364,13 @@ class S4LMenuBar(rumps.App):
|
|
|
319
364
|
act = st.read_activity()
|
|
320
365
|
label = act.get("label") if act else None
|
|
321
366
|
if label:
|
|
322
|
-
|
|
323
|
-
|
|
367
|
+
# A "✓" label (e.g. "posted 3/10 ✓") is a momentary confirmation, not
|
|
368
|
+
# ongoing work — show it without the spinner glyph so it reads as done.
|
|
369
|
+
if "✓" in label:
|
|
370
|
+
self.title = f"S4L {label}"
|
|
371
|
+
else:
|
|
372
|
+
self._spin_i = (self._spin_i + 1) % len(SPINNER)
|
|
373
|
+
self.title = f"S4L {label} {SPINNER[self._spin_i]}"
|
|
324
374
|
return
|
|
325
375
|
try:
|
|
326
376
|
if self._spinner is not None:
|
|
@@ -431,6 +481,7 @@ class S4LMenuBar(rumps.App):
|
|
|
431
481
|
self._review_active = False
|
|
432
482
|
sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
|
|
433
483
|
sys.stderr.flush()
|
|
484
|
+
_capture(e, phase="review_cards")
|
|
434
485
|
|
|
435
486
|
def _on_review_done(self, batch, decisions):
|
|
436
487
|
# Runs on the main thread (from the card controller). Translate decisions
|
|
@@ -589,11 +640,19 @@ class S4LMenuBar(rumps.App):
|
|
|
589
640
|
out.append(
|
|
590
641
|
rumps.MenuItem("Run draft cycle in Claude", callback=self._draft)
|
|
591
642
|
)
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
)
|
|
643
|
+
# No "Post approved drafts" item: approving a review card already posts
|
|
644
|
+
# directly + programmatically (_on_review_done -> st.post_drafts -> the
|
|
645
|
+
# CDP poster). A menu button that types a prompt into the chat to do the
|
|
646
|
+
# same thing was a redundant detour through the model, so it's gone.
|
|
595
647
|
return out
|
|
596
648
|
|
|
597
649
|
|
|
598
650
|
if __name__ == "__main__":
|
|
599
|
-
|
|
651
|
+
try:
|
|
652
|
+
S4LMenuBar().run()
|
|
653
|
+
except Exception as _run_err:
|
|
654
|
+
# The run loop dying is the other "menu bar didn't start / vanished" case.
|
|
655
|
+
# Report + flush before the KeepAlive relaunch so it isn't lost on teardown.
|
|
656
|
+
_capture(_run_err, phase="run")
|
|
657
|
+
_flush()
|
|
658
|
+
raise
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/social-autoposter-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.83",
|
|
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
package/scripts/post_reddit.py
CHANGED
|
@@ -36,6 +36,17 @@ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
|
|
|
36
36
|
REDDIT_BROWSER = os.path.join(REPO_DIR, "scripts", "reddit_browser.py")
|
|
37
37
|
REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
|
|
38
38
|
REDDIT_TOOLS = os.path.join(REPO_DIR, "scripts", "reddit_tools.py")
|
|
39
|
+
|
|
40
|
+
# Interpreter every child subprocess must run under. A bare PYTHON resolved
|
|
41
|
+
# to the user's system python, which lacks the pipeline deps (Playwright and
|
|
42
|
+
# friends) that live only in the owned uv runtime — so on a fresh box every
|
|
43
|
+
# reddit_browser.py reply died (the same class as the Karol/Twitter bug,
|
|
44
|
+
# 2026-06-22). Honor the authoritative SAPS_PYTHON pin (set by the launchd
|
|
45
|
+
# plist), else sys.executable (the owned interpreter the MCP launches us under).
|
|
46
|
+
# Never the literal PYTHON: that re-rolls the PATH dice. Re-exported so
|
|
47
|
+
# grandchildren inherit it.
|
|
48
|
+
PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
|
|
49
|
+
os.environ["SAPS_PYTHON"] = PYTHON
|
|
39
50
|
RATELIMIT_FILE = "/tmp/reddit_ratelimit.json"
|
|
40
51
|
PREFLIGHT_WAIT_BUDGET_SECONDS = 180
|
|
41
52
|
|
|
@@ -619,7 +630,7 @@ def load_config():
|
|
|
619
630
|
|
|
620
631
|
def pick_project(platform="reddit", exclude=None):
|
|
621
632
|
try:
|
|
622
|
-
cmd = [
|
|
633
|
+
cmd = [PYTHON, os.path.join(REPO_DIR, "scripts", "pick_project.py"),
|
|
623
634
|
"--platform", platform, "--json"]
|
|
624
635
|
if exclude:
|
|
625
636
|
cmd.extend(["--exclude", ",".join(exclude)])
|
|
@@ -641,7 +652,7 @@ def get_top_performers(project_name, platform="reddit", style=None):
|
|
|
641
652
|
callers that have not flipped to the picker yet).
|
|
642
653
|
"""
|
|
643
654
|
try:
|
|
644
|
-
cmd = [
|
|
655
|
+
cmd = [PYTHON, os.path.join(REPO_DIR, "scripts", "top_performers.py"),
|
|
645
656
|
"--platform", platform, "--project", project_name]
|
|
646
657
|
if style:
|
|
647
658
|
cmd.extend(["--style", style])
|
|
@@ -660,7 +671,7 @@ def get_top_search_topics(project_name, platform="reddit", limit=8, window_days=
|
|
|
660
671
|
project on this platform, or '' if no data yet. See top_search_topics.py."""
|
|
661
672
|
try:
|
|
662
673
|
result = subprocess.run(
|
|
663
|
-
[
|
|
674
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "top_search_topics.py"),
|
|
664
675
|
"--project", project_name, "--platform", platform,
|
|
665
676
|
"--window-days", str(window_days), "--limit", str(limit)],
|
|
666
677
|
capture_output=True, text=True, timeout=15,
|
|
@@ -684,7 +695,7 @@ def get_omitted_reddit_topics(project_name, limit=10, window_hours=168, min_omit
|
|
|
684
695
|
"""
|
|
685
696
|
try:
|
|
686
697
|
result = subprocess.run(
|
|
687
|
-
[
|
|
698
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "top_omitted_reddit_topics.py"),
|
|
688
699
|
"--project", project_name,
|
|
689
700
|
"--window-hours", str(window_hours),
|
|
690
701
|
"--limit", str(limit),
|
|
@@ -709,7 +720,7 @@ def get_dud_reddit_queries(project_name, limit=15, window_hours=168):
|
|
|
709
720
|
"""
|
|
710
721
|
try:
|
|
711
722
|
result = subprocess.run(
|
|
712
|
-
[
|
|
723
|
+
[PYTHON, os.path.join(REPO_DIR, "scripts", "top_dud_reddit_queries.py"),
|
|
713
724
|
"--project", project_name,
|
|
714
725
|
"--window-hours", str(window_hours),
|
|
715
726
|
"--limit", str(limit)],
|
|
@@ -1451,7 +1462,7 @@ def run_claude(prompt, timeout=600):
|
|
|
1451
1462
|
text_output = "\n".join(all_text_parts) if all_text_parts else "".join(collected)
|
|
1452
1463
|
stderr_out = proc.stderr.read() if proc.stderr else ""
|
|
1453
1464
|
try:
|
|
1454
|
-
log_args = [
|
|
1465
|
+
log_args = [PYTHON, os.path.join(REPO_DIR, "scripts", "log_claude_session.py"),
|
|
1455
1466
|
"--session-id", session_id, "--script", "post_reddit"]
|
|
1456
1467
|
orch_cost = usage.get("cost_usd")
|
|
1457
1468
|
if isinstance(orch_cost, (int, float)) and orch_cost > 0:
|
|
@@ -1487,7 +1498,7 @@ def _acquire_browser_lease(timeout: int = 600, ttl: int = 90):
|
|
|
1487
1498
|
"""
|
|
1488
1499
|
try:
|
|
1489
1500
|
r = subprocess.run(
|
|
1490
|
-
[
|
|
1501
|
+
[PYTHON, REDDIT_BROWSER_LOCK, "acquire",
|
|
1491
1502
|
"--timeout", str(timeout), "--ttl", str(ttl)],
|
|
1492
1503
|
capture_output=True, text=True, timeout=timeout + 30,
|
|
1493
1504
|
)
|
|
@@ -1512,7 +1523,7 @@ def _release_browser_lease() -> None:
|
|
|
1512
1523
|
"""
|
|
1513
1524
|
try:
|
|
1514
1525
|
subprocess.run(
|
|
1515
|
-
[
|
|
1526
|
+
[PYTHON, REDDIT_BROWSER_LOCK, "release"],
|
|
1516
1527
|
capture_output=True, text=True, timeout=10,
|
|
1517
1528
|
)
|
|
1518
1529
|
except Exception:
|
|
@@ -1529,7 +1540,7 @@ def post_via_cdp(thread_url, reply_to_url, text):
|
|
|
1529
1540
|
for attempt in range(MAX_ATTEMPTS):
|
|
1530
1541
|
try:
|
|
1531
1542
|
target = reply_to_url or thread_url
|
|
1532
|
-
cmd = [
|
|
1543
|
+
cmd = [PYTHON, REDDIT_BROWSER, "reply" if reply_to_url else "post-comment", target, text]
|
|
1533
1544
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
1534
1545
|
cdp_out = proc.stdout.strip()
|
|
1535
1546
|
if not cdp_out:
|
|
@@ -1585,7 +1596,7 @@ def log_post(thread_url, permalink, text, project_name, thread_author, thread_ti
|
|
|
1585
1596
|
(if any) Claude baked into the reply text.
|
|
1586
1597
|
"""
|
|
1587
1598
|
try:
|
|
1588
|
-
cmd = [
|
|
1599
|
+
cmd = [PYTHON, REDDIT_TOOLS, "log-post",
|
|
1589
1600
|
thread_url, permalink or "", text, project_name,
|
|
1590
1601
|
thread_author, thread_title,
|
|
1591
1602
|
"--account", reddit_username]
|
|
@@ -1616,7 +1627,7 @@ def bump_campaigns(table, row_id, campaign_ids):
|
|
|
1616
1627
|
for cid in campaign_ids:
|
|
1617
1628
|
try:
|
|
1618
1629
|
subprocess.run(
|
|
1619
|
-
[
|
|
1630
|
+
[PYTHON, bump,
|
|
1620
1631
|
"--table", table, "--id", str(row_id), "--campaign-id", str(cid)],
|
|
1621
1632
|
capture_output=True, text=True, timeout=15,
|
|
1622
1633
|
)
|
|
@@ -2623,6 +2634,22 @@ def main():
|
|
|
2623
2634
|
sys.exit(2)
|
|
2624
2635
|
with open(args.in_path) as f:
|
|
2625
2636
|
plan = json.load(f)
|
|
2637
|
+
# Hard preflight: _post_iteration shells to reddit_browser.py, the only
|
|
2638
|
+
# Playwright importer on this rail. If the resolved interpreter can't
|
|
2639
|
+
# import it the owned runtime is missing/half-provisioned and every post
|
|
2640
|
+
# would die with CDP_ERROR. Fail LOUD with a distinct signal instead.
|
|
2641
|
+
# Gated on real decisions so an empty plan still exits clean.
|
|
2642
|
+
if plan.get("decisions"):
|
|
2643
|
+
_chk = subprocess.run(
|
|
2644
|
+
[PYTHON, "-c", "import playwright"],
|
|
2645
|
+
capture_output=True, text=True,
|
|
2646
|
+
)
|
|
2647
|
+
if _chk.returncode != 0:
|
|
2648
|
+
print(f"[post_reddit] FATAL runtime_incomplete: interpreter {PYTHON!r} "
|
|
2649
|
+
f"cannot import playwright — the owned Python runtime is missing or "
|
|
2650
|
+
f"unprovisioned. Run the `runtime` install (action:'install') before "
|
|
2651
|
+
f"posting. stderr: {(_chk.stderr or '').strip()[:300]}", file=sys.stderr)
|
|
2652
|
+
sys.exit(3)
|
|
2626
2653
|
try:
|
|
2627
2654
|
posted, failed = _post_iteration(plan, reddit_username)
|
|
2628
2655
|
print(f"[post_reddit] phase=post project={plan.get('project_name')} posted={posted} failed={failed}")
|
|
@@ -433,4 +433,21 @@ def main() -> int:
|
|
|
433
433
|
|
|
434
434
|
|
|
435
435
|
if __name__ == "__main__":
|
|
436
|
-
|
|
436
|
+
try:
|
|
437
|
+
_rc = main()
|
|
438
|
+
except BrokenPipeError:
|
|
439
|
+
# The MCP setup hook (our parent) closes stdout once it has read the
|
|
440
|
+
# sentinel ===QUERIES_JSON=== block; the trailing summary prints then hit
|
|
441
|
+
# a dead pipe and raise BrokenPipeError. All persistence already happened
|
|
442
|
+
# earlier in main(), so this is BENIGN. Previously it propagated as an
|
|
443
|
+
# uncaught exception and Sentry logged it as a "seeding failed" event
|
|
444
|
+
# (Karol, 2026-06-22) — a false positive that buried the real signal.
|
|
445
|
+
# Redirect stdout to devnull so interpreter shutdown doesn't re-raise on
|
|
446
|
+
# the final flush, then exit clean.
|
|
447
|
+
try:
|
|
448
|
+
_devnull = os.open(os.devnull, os.O_WRONLY)
|
|
449
|
+
os.dup2(_devnull, sys.stdout.fileno())
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
_rc = 0
|
|
453
|
+
raise SystemExit(_rc)
|
package/scripts/sentry_init.py
CHANGED
|
@@ -65,3 +65,40 @@ def _tag_install(sentry_sdk) -> None:
|
|
|
65
65
|
sentry_sdk.set_tag("hostname", str(host))
|
|
66
66
|
except Exception:
|
|
67
67
|
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def capture_exception(err, tags=None) -> None:
|
|
71
|
+
"""Explicitly report an exception to Sentry with optional tags. Safe to call
|
|
72
|
+
even if init() was never run or sentry-sdk is missing (silent no-op). Use for
|
|
73
|
+
swallowed/handled errors that would otherwise never reach Sentry (the global
|
|
74
|
+
excepthook only catches UNHANDLED ones)."""
|
|
75
|
+
if not _initialized:
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
import sentry_sdk
|
|
79
|
+
except Exception:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
if tags:
|
|
83
|
+
with sentry_sdk.push_scope() as scope:
|
|
84
|
+
for k, v in tags.items():
|
|
85
|
+
scope.set_tag(str(k), str(v))
|
|
86
|
+
sentry_sdk.capture_exception(err)
|
|
87
|
+
else:
|
|
88
|
+
sentry_sdk.capture_exception(err)
|
|
89
|
+
except Exception:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def flush(timeout: float = 2.0) -> None:
|
|
94
|
+
"""Block until queued events are sent (best-effort). Call before a short-lived
|
|
95
|
+
or about-to-crash process exits so a just-captured event isn't dropped on
|
|
96
|
+
teardown."""
|
|
97
|
+
if not _initialized:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
import sentry_sdk
|
|
101
|
+
|
|
102
|
+
sentry_sdk.flush(timeout)
|
|
103
|
+
except Exception:
|
|
104
|
+
return
|
|
@@ -45,6 +45,7 @@ import random
|
|
|
45
45
|
import re
|
|
46
46
|
import subprocess
|
|
47
47
|
import sys
|
|
48
|
+
import time
|
|
48
49
|
from datetime import datetime, timezone
|
|
49
50
|
from pathlib import Path
|
|
50
51
|
|
|
@@ -54,6 +55,18 @@ LOG_POST = os.path.join(REPO_DIR, "scripts", "log_post.py")
|
|
|
54
55
|
CAMPAIGN_BUMP = os.path.join(REPO_DIR, "scripts", "campaign_bump.py")
|
|
55
56
|
LINK_TAIL = os.path.join(REPO_DIR, "scripts", "link_tail.py")
|
|
56
57
|
|
|
58
|
+
# Interpreter every child subprocess (twitter_browser.py reply, log_post.py,
|
|
59
|
+
# campaign_bump.py, link_tail.py) must run under. The reply path is the only
|
|
60
|
+
# Playwright importer in the pipeline, so a bare "python3" here silently
|
|
61
|
+
# resolved to the user's system python (no Playwright) and every post died
|
|
62
|
+
# with no_reply_json (Karol, 2026-06-22). Honor the authoritative pin the rest
|
|
63
|
+
# of the runtime uses — SAPS_PYTHON (set by the launchd plist) — then fall back
|
|
64
|
+
# to sys.executable (the interpreter THIS process already runs under, which the
|
|
65
|
+
# MCP's runPython resolves to the owned uv runtime). Never the literal
|
|
66
|
+
# "python3": that re-rolls the PATH dice. Re-exported so grandchildren inherit.
|
|
67
|
+
PYTHON = os.environ.get("SAPS_PYTHON") or sys.executable
|
|
68
|
+
os.environ["SAPS_PYTHON"] = PYTHON
|
|
69
|
+
|
|
57
70
|
# DATABASE_URL was previously used to issue ad-hoc `psql -c "..."` calls for
|
|
58
71
|
# the pre-post dedup probe and the candidate status updates. As of the
|
|
59
72
|
# 2026-05-18 routes migration both lanes go through the s4l.ai HTTP API
|
|
@@ -526,7 +539,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
526
539
|
link_tail_outcome = "skipped_no_link"
|
|
527
540
|
if _add_tail_link:
|
|
528
541
|
rc, out, err = run_subprocess(
|
|
529
|
-
[
|
|
542
|
+
[PYTHON, LINK_TAIL,
|
|
530
543
|
"--reply-text", reply_text,
|
|
531
544
|
"--link-url", link_url,
|
|
532
545
|
"--thread-text", thread_text or "",
|
|
@@ -590,7 +603,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
590
603
|
|
|
591
604
|
print(f"[post] candidate {cid} -> posting (link={link_url!r})", flush=True)
|
|
592
605
|
rc, out, err = run_subprocess(
|
|
593
|
-
[
|
|
606
|
+
[PYTHON, TWITTER_BROWSER, "reply", candidate_url, full_text],
|
|
594
607
|
timeout_sec=600,
|
|
595
608
|
)
|
|
596
609
|
if err:
|
|
@@ -667,7 +680,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
667
680
|
from twitter_account import resolve_handle as _resolve_twitter_handle
|
|
668
681
|
|
|
669
682
|
log_args = [
|
|
670
|
-
|
|
683
|
+
PYTHON, LOG_POST,
|
|
671
684
|
"--platform", "twitter",
|
|
672
685
|
"--thread-url", candidate_url,
|
|
673
686
|
"--our-url", reply_url,
|
|
@@ -765,7 +778,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
765
778
|
# Campaign attribution.
|
|
766
779
|
for ccid in applied_campaigns:
|
|
767
780
|
rc, out, err = run_subprocess(
|
|
768
|
-
[
|
|
781
|
+
[PYTHON, CAMPAIGN_BUMP, "--table", "posts",
|
|
769
782
|
"--id", str(post_id), "--campaign-id", str(ccid)],
|
|
770
783
|
timeout_sec=30,
|
|
771
784
|
)
|
|
@@ -777,7 +790,7 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
777
790
|
# Mark link_edited_at: link is embedded in primary reply, no self-reply
|
|
778
791
|
# will follow. Prevents link-edit-twitter sweep from re-attempting.
|
|
779
792
|
rc, out, err = run_subprocess(
|
|
780
|
-
[
|
|
793
|
+
[PYTHON, LOG_POST,
|
|
781
794
|
"--mark-self-reply",
|
|
782
795
|
"--post-id", str(post_id),
|
|
783
796
|
"--self-reply-url", reply_url,
|
|
@@ -845,6 +858,39 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
|
|
|
845
858
|
return ("posted", "")
|
|
846
859
|
|
|
847
860
|
|
|
861
|
+
def _saps_state_dir() -> str:
|
|
862
|
+
return os.environ.get("SAPS_STATE_DIR") or os.path.join(
|
|
863
|
+
os.path.expanduser("~"), ".social-autoposter-mcp")
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _write_activity(label: str) -> None:
|
|
867
|
+
"""Best-effort live status for the S4L menu bar, which polls
|
|
868
|
+
<state_dir>/activity.json. Mirrors the Node server's writeActivity shape so
|
|
869
|
+
the menu-bar spinner renders our per-post progress ("posting 3/10", then
|
|
870
|
+
"posted 3/10 ✓"). Purely cosmetic: a failure here never affects posting."""
|
|
871
|
+
try:
|
|
872
|
+
sd = _saps_state_dir()
|
|
873
|
+
os.makedirs(sd, exist_ok=True)
|
|
874
|
+
payload = {"state": "working", "label": label,
|
|
875
|
+
"since": datetime.now(timezone.utc).isoformat()}
|
|
876
|
+
with open(os.path.join(sd, "activity.json"), "w", encoding="utf-8") as f:
|
|
877
|
+
f.write(json.dumps(payload) + "\n")
|
|
878
|
+
except Exception:
|
|
879
|
+
pass
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _clear_activity() -> None:
|
|
883
|
+
"""Remove our status so neither an early exit nor the cron path (which does
|
|
884
|
+
NOT go through the MCP runTool's clear) leaves a stale 'posting/posted' stuck
|
|
885
|
+
in the menu bar. Double-clearing with runTool is harmless."""
|
|
886
|
+
try:
|
|
887
|
+
p = os.path.join(_saps_state_dir(), "activity.json")
|
|
888
|
+
if os.path.exists(p):
|
|
889
|
+
os.remove(p)
|
|
890
|
+
except Exception:
|
|
891
|
+
pass
|
|
892
|
+
|
|
893
|
+
|
|
848
894
|
def main() -> int:
|
|
849
895
|
ap = argparse.ArgumentParser()
|
|
850
896
|
ap.add_argument("--plan", required=True,
|
|
@@ -924,26 +970,63 @@ def main() -> int:
|
|
|
924
970
|
f"approved in plan (pass --post-unapproved to override)", flush=True)
|
|
925
971
|
candidates = _kept
|
|
926
972
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
973
|
+
# Hard preflight: the reply path (twitter_browser.py) imports Playwright,
|
|
974
|
+
# the only such importer in the pipeline. If the resolved interpreter can't
|
|
975
|
+
# import it, EVERY post dies with no_reply_json because the owned runtime is
|
|
976
|
+
# missing or half-provisioned (Karol, 2026-06-22). Fail LOUD here with a
|
|
977
|
+
# distinct signal instead of attempting posts that silently no-op. Gated on
|
|
978
|
+
# there being real work, so a no-op / all-skipped plan still exits clean.
|
|
979
|
+
if candidates:
|
|
980
|
+
_chk = subprocess.run(
|
|
981
|
+
[PYTHON, "-c", "import playwright"],
|
|
982
|
+
capture_output=True, text=True,
|
|
983
|
+
)
|
|
984
|
+
if _chk.returncode != 0:
|
|
985
|
+
print(f"[post] FATAL runtime_incomplete: interpreter {PYTHON!r} cannot "
|
|
986
|
+
f"import playwright — the owned Python runtime is missing or "
|
|
987
|
+
f"unprovisioned. Run the `runtime` install (action:'install') "
|
|
988
|
+
f"before posting. stderr: {(_chk.stderr or '').strip()[:300]}",
|
|
989
|
+
file=sys.stderr, flush=True)
|
|
990
|
+
print(json.dumps({
|
|
991
|
+
"posted": 0,
|
|
992
|
+
"skipped": 0,
|
|
993
|
+
"failed": len(candidates),
|
|
994
|
+
"failure_reasons": "runtime_incomplete",
|
|
995
|
+
"skip_reasons": "",
|
|
996
|
+
}), flush=True)
|
|
997
|
+
return 3
|
|
998
|
+
|
|
999
|
+
_total = len(candidates)
|
|
1000
|
+
try:
|
|
1001
|
+
for _idx, c in enumerate(candidates, start=1):
|
|
1002
|
+
# Live per-post status for the S4L menu bar: "posting 3/10" while this
|
|
1003
|
+
# one is in flight, then "posted 3/10 ✓" once it lands. Cosmetic only.
|
|
1004
|
+
_write_activity(f"posting {_idx}/{_total}")
|
|
1005
|
+
try:
|
|
1006
|
+
outcome, reason = post_one(c, picker_assignment=picker_assignment)
|
|
1007
|
+
except Exception as e:
|
|
1008
|
+
print(f"[post] candidate {c.get('candidate_id')} crashed: {e}",
|
|
1009
|
+
flush=True)
|
|
1010
|
+
outcome, reason = ("failed", "exception")
|
|
1011
|
+
cid = c.get("candidate_id")
|
|
1012
|
+
if isinstance(cid, int):
|
|
1013
|
+
update_candidate(cid, "skipped", "exception")
|
|
1014
|
+
if outcome == "posted":
|
|
1015
|
+
posted += 1
|
|
1016
|
+
# Flash the confirmation with a short dwell so the menu bar shows
|
|
1017
|
+
# it before the next iteration's "posting" overwrites the label.
|
|
1018
|
+
_write_activity(f"posted {_idx}/{_total} ✓")
|
|
1019
|
+
time.sleep(0.6)
|
|
1020
|
+
elif outcome == "skipped":
|
|
1021
|
+
skipped += 1
|
|
1022
|
+
if reason:
|
|
1023
|
+
skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
|
|
1024
|
+
else:
|
|
1025
|
+
failed += 1
|
|
1026
|
+
if reason:
|
|
1027
|
+
fail_reasons[reason] = fail_reasons.get(reason, 0) + 1
|
|
1028
|
+
finally:
|
|
1029
|
+
_clear_activity()
|
|
947
1030
|
|
|
948
1031
|
summary = {
|
|
949
1032
|
"posted": posted,
|
|
@@ -2070,7 +2070,7 @@ log "twitter-browser lock held (pid=$$) Phase 2b-post"
|
|
|
2070
2070
|
ensure_twitter_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|
|
2071
2071
|
|
|
2072
2072
|
log "Phase 2b-post: posting $PLAN_COUNT candidate(s)..."
|
|
2073
|
-
POST_OUTPUT=$(python3 "$REPO_DIR/scripts/twitter_post_plan.py" --plan "$PLAN_FILE" 2>&1)
|
|
2073
|
+
POST_OUTPUT=$("${SAPS_PYTHON:-python3}" "$REPO_DIR/scripts/twitter_post_plan.py" --plan "$PLAN_FILE" 2>&1)
|
|
2074
2074
|
echo "$POST_OUTPUT" >> "$LOG_FILE"
|
|
2075
2075
|
|
|
2076
2076
|
# The post helper prints a JSON summary on its last stdout line.
|