@m13v/s4l 1.6.203-rc.8 → 1.6.203
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 +118 -87
- package/mcp/dist/panel.html +1 -1
- package/mcp/dist/product-link.html +9 -9
- package/mcp/dist/repo.js +1 -1
- package/mcp/dist/runtime.js +7 -13
- package/mcp/dist/setup.js +3 -3
- package/mcp/dist/telemetry.js +6 -8
- package/mcp/dist/version.js +1 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/install.mjs +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/dashboard_server.py +3 -6
- package/mcp/menubar/s4l_card.py +171 -42
- package/mcp/menubar/s4l_menubar.py +80 -28
- package/mcp/menubar/s4l_state.py +23 -5
- package/mcp/package.json +2 -2
- package/mcp/shared/doctor.cjs +0 -2
- package/mcp/shared/onboarding-ledger.cjs +0 -1
- package/mcp-servers/browser-harness/server.py +2 -2
- package/package.json +1 -1
- package/requirements.txt +3 -3
- package/scripts/active_experiments.py +112 -0
- package/scripts/apply_onboarding_selections.py +5 -5
- package/scripts/build_persona.py +5 -5
- package/scripts/claude_job.py +16 -3
- package/scripts/copy_browser_cookies.py +1 -1
- package/scripts/dev_dashboard_server.py +1 -1
- package/scripts/feedback_digest.py +18 -1
- package/scripts/harness_overlay.py +17 -17
- package/scripts/log_draft.py +1 -1
- package/scripts/log_post.py +3 -3
- package/scripts/memory_snapshot.py +3 -3
- package/scripts/merge_review_queue.py +6 -0
- package/scripts/reap_stale_claude_sessions.py +3 -3
- package/scripts/relay_provider_log.py +186 -0
- package/scripts/s4l_activity.py +126 -0
- package/scripts/s4l_mode.py +414 -0
- package/scripts/salvage_orphaned_prep_results.py +178 -0
- package/scripts/saps_activity.py +10 -118
- package/scripts/saps_mode.py +8 -387
- package/scripts/setup_twitter_auth.py +1 -1
- package/scripts/snapshot.py +1 -1
- package/scripts/twitter_post_plan.py +88 -13
- package/scripts/watchdog_hung_runs.py +12 -12
- package/skill/engage-linkedin.sh +4 -4
- package/skill/engage-twitter.sh +4 -4
- package/skill/lib/linkedin-backend.sh +1 -1
- package/skill/run-draft-and-publish.sh +83 -34
- package/skill/run-instagram-render.sh +3 -3
- package/skill/run-linkedin.sh +5 -5
- package/skill/run-reddit-threads.sh +5 -5
- package/skill/run-twitter-cycle.sh +91 -50
- package/skill/social-autoposter-update.sh +3 -1
- package/skill/styles.sh +10 -10
- package/skill/topics.sh +8 -8
- package/skill/run-cycle-update-guard.sh +0 -44
- package/skill/run-twitter-cycle-launchd.sh +0 -63
- package/skill/run-twitter-cycle-singleton.sh +0 -62
package/mcp/dist/index.js
CHANGED
|
@@ -156,22 +156,6 @@ function pipelinePath() {
|
|
|
156
156
|
function launchdPath() {
|
|
157
157
|
return [...ownedBinDirs(), LAUNCHD_PATH].join(":");
|
|
158
158
|
}
|
|
159
|
-
// Brand rename 2026-07-03 (SAPS_ -> S4L_): duplicate every S4L_* key under its
|
|
160
|
-
// legacy SAPS_* name when emitting env into child processes / plists, so an
|
|
161
|
-
// old pipeline-script version still on disk during a partial update keeps
|
|
162
|
-
// resolving its env. Never overwrites an explicitly-set legacy key. Remove
|
|
163
|
-
// once no pre-rename scripts remain in the field.
|
|
164
|
-
function withSapsEnvCompat(env) {
|
|
165
|
-
const out = { ...env };
|
|
166
|
-
for (const [k, v] of Object.entries(env)) {
|
|
167
|
-
if (k.startsWith("S4L_")) {
|
|
168
|
-
const legacy = "SAPS_" + k.slice(4);
|
|
169
|
-
if (!(legacy in out))
|
|
170
|
-
out[legacy] = v;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return out;
|
|
174
|
-
}
|
|
175
159
|
function plistXml(opts) {
|
|
176
160
|
const args = opts.programArgs.map((a) => `\t\t<string>${a}</string>`).join("\n");
|
|
177
161
|
const schedule = opts.keepAlive
|
|
@@ -187,10 +171,8 @@ function plistXml(opts) {
|
|
|
187
171
|
: "";
|
|
188
172
|
// Caller-supplied env (e.g. the queue kicker's DRAFT_ONLY / S4L_CLAUDE_PROVIDER).
|
|
189
173
|
// Rendered after the baked-in vars so a caller can also override S4L_STATE_DIR.
|
|
190
|
-
// Dual-named (S4L_* + legacy SAPS_*) so a freshly-written plist still works
|
|
191
|
-
// with any pre-rename script version on disk during a partial update.
|
|
192
174
|
const extraEnv = opts.extraEnv
|
|
193
|
-
? Object.entries(
|
|
175
|
+
? Object.entries(opts.extraEnv)
|
|
194
176
|
.map(([k, v]) => `\n\t\t<key>${k}</key>\n\t\t<string>${v}</string>`)
|
|
195
177
|
.join("")
|
|
196
178
|
: "";
|
|
@@ -217,11 +199,7 @@ ${schedule}
|
|
|
217
199
|
\t\t<string>${os.homedir()}</string>
|
|
218
200
|
\t\t<key>S4L_REPO_DIR</key>
|
|
219
201
|
\t\t<string>${repoDir()}</string>
|
|
220
|
-
\t\t<key>SAPS_REPO_DIR</key>
|
|
221
|
-
\t\t<string>${repoDir()}</string>
|
|
222
202
|
\t\t<key>S4L_PYTHON</key>
|
|
223
|
-
\t\t<string>${resolvePython()}</string>
|
|
224
|
-
\t\t<key>SAPS_PYTHON</key>
|
|
225
203
|
\t\t<string>${resolvePython()}</string>${chromeEnv}${extraEnv}
|
|
226
204
|
\t</dict>
|
|
227
205
|
\t<key>RunAtLoad</key>
|
|
@@ -408,8 +386,8 @@ function toolActivityLabel(name, args) {
|
|
|
408
386
|
const fallback = TOOL_ACTIVITY[name];
|
|
409
387
|
if (!fallback)
|
|
410
388
|
return null;
|
|
411
|
-
const override = typeof args?.
|
|
412
|
-
? args.
|
|
389
|
+
const override = typeof args?.__s4l_activity_label === "string"
|
|
390
|
+
? args.__s4l_activity_label.replace(/\s+/g, " ").trim().slice(0, 80)
|
|
413
391
|
: "";
|
|
414
392
|
return override || fallback;
|
|
415
393
|
}
|
|
@@ -572,7 +550,7 @@ async function ensureOverlayWatch() {
|
|
|
572
550
|
try {
|
|
573
551
|
await run("bash", ["skill/run-overlay-watch.sh"], {
|
|
574
552
|
timeoutMs: 20_000,
|
|
575
|
-
env:
|
|
553
|
+
env: ({
|
|
576
554
|
S4L_PYTHON: resolvePython(),
|
|
577
555
|
S4L_LOG_DIR: path.join(repoDir(), "skill", "logs"),
|
|
578
556
|
TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
|
|
@@ -655,7 +633,7 @@ async function produceDrafts(project, onProgress) {
|
|
|
655
633
|
// (switched in onLine below). Cleared before every return.
|
|
656
634
|
writeActivity("scanning", "scanning X");
|
|
657
635
|
const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
|
|
658
|
-
env:
|
|
636
|
+
env: env,
|
|
659
637
|
timeoutMs: 900_000, // scan+draft can take several minutes
|
|
660
638
|
// Fan every cycle line out to THREE sinks so progress is never a black box:
|
|
661
639
|
// 1. draft_cycle-mcp.log — the stable, documented, host-independent file.
|
|
@@ -810,6 +788,16 @@ function parsePostCandidateResults(stdout) {
|
|
|
810
788
|
upsert(m[1], "skipped", "empty_reply_text");
|
|
811
789
|
continue;
|
|
812
790
|
}
|
|
791
|
+
m = /\[post\] candidate (\d+) log_post\.py did not return post_id/.exec(line);
|
|
792
|
+
if (m) {
|
|
793
|
+
// The reply IS live on X; only the posts-row INSERT failed (e.g. the
|
|
794
|
+
// 2026-07-06 draft_prompt_variant validation 400). Mark the card POSTED
|
|
795
|
+
// so the drain never re-attempts a live reply — re-posting slams into
|
|
796
|
+
// X's duplicate guard at best and double-posts at worst. The missing
|
|
797
|
+
// posts row is recoverable from the post-*.log (reply_url + final_text).
|
|
798
|
+
upsert(m[1], "posted", "log_post_no_id");
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
813
801
|
m = /\[post\] candidate (\d+) crashed:/.exec(line);
|
|
814
802
|
if (m)
|
|
815
803
|
upsert(m[1], "failed", "exception");
|
|
@@ -845,7 +833,7 @@ async function ensurePostingHandle() {
|
|
|
845
833
|
try {
|
|
846
834
|
await runPython("scripts/setup_twitter_auth.py", ["resolve-handle"], {
|
|
847
835
|
timeoutMs: 60_000,
|
|
848
|
-
env:
|
|
836
|
+
env: ({ S4L_REPO_DIR: repoDir(), PATH: pipelinePath() }),
|
|
849
837
|
});
|
|
850
838
|
}
|
|
851
839
|
catch {
|
|
@@ -864,7 +852,7 @@ async function ensureTwitterBrowserForPost() {
|
|
|
864
852
|
env.BH_CHROME_BIN = chrome;
|
|
865
853
|
return run("bash", ["-lc", ". skill/lib/twitter-backend.sh && ensure_twitter_browser_for_backend"], {
|
|
866
854
|
timeoutMs: 90_000,
|
|
867
|
-
env:
|
|
855
|
+
env: env,
|
|
868
856
|
onLine: (line) => {
|
|
869
857
|
const t = line.replace(/\s+$/, "");
|
|
870
858
|
if (t.trim())
|
|
@@ -940,7 +928,7 @@ async function postApproved(batchId, plan) {
|
|
|
940
928
|
}
|
|
941
929
|
return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
|
|
942
930
|
timeoutMs: 900_000,
|
|
943
|
-
env:
|
|
931
|
+
env: ({
|
|
944
932
|
S4L_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
945
933
|
// Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
|
|
946
934
|
// runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
|
|
@@ -1202,7 +1190,7 @@ async function seedSearchQueriesForProject(project, rawQueries) {
|
|
|
1202
1190
|
};
|
|
1203
1191
|
}
|
|
1204
1192
|
try {
|
|
1205
|
-
const qfile = path.join(os.tmpdir(), `
|
|
1193
|
+
const qfile = path.join(os.tmpdir(), `s4l-queries-${project}-${Date.now()}.json`);
|
|
1206
1194
|
fs.writeFileSync(qfile, JSON.stringify({ queries: agentQueries.map((q) => ({ query: q, topic: "" })) }));
|
|
1207
1195
|
seedInFlight.set(project, Date.now());
|
|
1208
1196
|
// Fire-and-forget: runPython keeps the output on the repo.ts tee (so the
|
|
@@ -1247,7 +1235,7 @@ async function seedSearchQueriesForProject(project, rawQueries) {
|
|
|
1247
1235
|
// ---- engagement_mode: choose personal-brand vs product (setup-time) --------
|
|
1248
1236
|
// Part of onboarding: AFTER X connect + profile_scan, BEFORE product config, the
|
|
1249
1237
|
// agent asks the user which mode they want and calls this. It persists the mode
|
|
1250
|
-
// (scripts/
|
|
1238
|
+
// (scripts/s4l_mode.py, the single source of truth the cycle reads) and
|
|
1251
1239
|
// provisions the persona project (grounded in the profile scan), then the agent
|
|
1252
1240
|
// continues to product setup — a product is always configured regardless of mode.
|
|
1253
1241
|
tool("engagement_mode", {
|
|
@@ -1323,7 +1311,7 @@ tool("engagement_mode", {
|
|
|
1323
1311
|
}, async (args) => {
|
|
1324
1312
|
const action = args.action || "get";
|
|
1325
1313
|
const readFlags = async () => {
|
|
1326
|
-
const cur = await runPython("scripts/
|
|
1314
|
+
const cur = await runPython("scripts/s4l_mode.py", ["flags"], { timeoutMs: 15_000 });
|
|
1327
1315
|
try {
|
|
1328
1316
|
const f = JSON.parse((cur.stdout || "").trim());
|
|
1329
1317
|
return { personal_brand: !!f.personal_brand, promotion: !!f.promotion };
|
|
@@ -1339,11 +1327,11 @@ tool("engagement_mode", {
|
|
|
1339
1327
|
return jsonContent({ flags, mode, persona: persona ? persona.name : null });
|
|
1340
1328
|
}
|
|
1341
1329
|
// Lightweight flip of ONE lane (the dashboard/menu-bar quick toggle): just
|
|
1342
|
-
// rewrite mode.json via
|
|
1330
|
+
// rewrite mode.json via s4l_mode.py — NO persona provisioning. Mirrors the
|
|
1343
1331
|
// menu bar's pure-local _toggle_lane so flipping from either surface is cheap.
|
|
1344
1332
|
if (action === "toggle") {
|
|
1345
1333
|
const lane = args.lane === "promotion" ? "promotion" : "personal_brand";
|
|
1346
|
-
const res = await runPython("scripts/
|
|
1334
|
+
const res = await runPython("scripts/s4l_mode.py", ["toggle", lane], { timeoutMs: 15_000 });
|
|
1347
1335
|
if (res.code !== 0) {
|
|
1348
1336
|
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
1349
1337
|
return textContent(`Could not switch lane: ${tail}`);
|
|
@@ -1373,16 +1361,16 @@ tool("engagement_mode", {
|
|
|
1373
1361
|
}
|
|
1374
1362
|
const mode = personalBrand ? "personal_brand" : "promotion";
|
|
1375
1363
|
recordOnboardingAttempt("mode_chosen", { personal_brand: personalBrand, promotion });
|
|
1376
|
-
const setRes = await runPython("scripts/
|
|
1364
|
+
const setRes = await runPython("scripts/s4l_mode.py", ["set-flags", personalBrand ? "1" : "0", promotion ? "1" : "0"], { timeoutMs: 15_000 });
|
|
1377
1365
|
if (setRes.code !== 0) {
|
|
1378
1366
|
const tail = (setRes.stderr || setRes.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
1379
1367
|
blockOnboardingMilestone("mode_chosen", "mode_set_failed", tail, { personal_brand: personalBrand, promotion });
|
|
1380
1368
|
return textContent(`Couldn't save the engagement lanes: ${tail}`);
|
|
1381
1369
|
}
|
|
1382
|
-
// NOTE (2026-07-06): the
|
|
1370
|
+
// NOTE (2026-07-06): the draft-only flag (mode.json {"draft_only": false} —
|
|
1383
1371
|
// promotion cycles POST autonomously instead of drafting cards) is
|
|
1384
1372
|
// DELIBERATELY NOT exposed on this tool or any user surface. It is an
|
|
1385
|
-
// operator-only switch, set via `scripts/
|
|
1373
|
+
// operator-only switch, set via `scripts/s4l_mode.py draft-only on|off`.
|
|
1386
1374
|
// run-draft-and-publish.sh reads it per cycle. Do not add a tool param,
|
|
1387
1375
|
// menubar toggle, or onboarding step for it.
|
|
1388
1376
|
// Provision the persona (grounded from the scan when supplied) regardless of
|
|
@@ -2502,7 +2490,7 @@ async function autopilotLoaded() {
|
|
|
2502
2490
|
// Claude turn, writes the result back, and stops.
|
|
2503
2491
|
// ===========================================================================
|
|
2504
2492
|
const QUEUE_WORKER_PROMPT_VERSION = 7; // v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
|
|
2505
|
-
const QUEUE_WORKER_PROMPT_MARKER = "
|
|
2493
|
+
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
2506
2494
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
2507
2495
|
const QUEUE_WORKERS = [
|
|
2508
2496
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -2519,7 +2507,7 @@ function scheduledTaskSkillPath(taskId) {
|
|
|
2519
2507
|
// The queue dir the worker reads/writes. MUST equal what the launchd kicker sets
|
|
2520
2508
|
// (kickerEnv below) and what claude_job.py uses, so both ends meet on one path.
|
|
2521
2509
|
function queueDir() {
|
|
2522
|
-
return path.join(
|
|
2510
|
+
return path.join(s4lStateDir(), "claude-queue");
|
|
2523
2511
|
}
|
|
2524
2512
|
// A draft job left unclaimed in pending/ this long (ms) means no scheduled-task
|
|
2525
2513
|
// routine is draining the queue — the worker would claim within a minute if it
|
|
@@ -2614,7 +2602,7 @@ function queueWorkerCwd() {
|
|
|
2614
2602
|
function queueWorkerBody(spec) {
|
|
2615
2603
|
const py = resolvePython();
|
|
2616
2604
|
const job = path.join(repoDir(), "scripts", "claude_job.py");
|
|
2617
|
-
const sd =
|
|
2605
|
+
const sd = s4lStateDir();
|
|
2618
2606
|
const outDir = queueDir();
|
|
2619
2607
|
return [
|
|
2620
2608
|
`You are the S4L queue worker. Run ONE iteration, then STOP.`,
|
|
@@ -2774,13 +2762,6 @@ function queueWorkerAllowedTools() {
|
|
|
2774
2762
|
"mcp__S4L__project_config",
|
|
2775
2763
|
"mcp__S4L__get_stats",
|
|
2776
2764
|
"mcp__S4L__dashboard",
|
|
2777
|
-
// Legacy "SAPS" protocol-name namespace (pre-2026-07 brand rename): old
|
|
2778
|
-
// registrations still resolve tool ids under it, keep the allow-rules.
|
|
2779
|
-
"mcp__SAPS__queue_setup",
|
|
2780
|
-
"mcp__SAPS__post_drafts",
|
|
2781
|
-
"mcp__SAPS__project_config",
|
|
2782
|
-
"mcp__SAPS__get_stats",
|
|
2783
|
-
"mcp__SAPS__dashboard",
|
|
2784
2765
|
];
|
|
2785
2766
|
}
|
|
2786
2767
|
// Merge a list of allow-rules into ~/.claude/settings.json. Returns count added.
|
|
@@ -2911,7 +2892,7 @@ function ensureWorkerFolderTrusted() {
|
|
|
2911
2892
|
// S4L_COWORK_MCP=0.
|
|
2912
2893
|
function ensureCoworkMcpRegistered() {
|
|
2913
2894
|
try {
|
|
2914
|
-
if ((process.env.S4L_COWORK_MCP
|
|
2895
|
+
if ((process.env.S4L_COWORK_MCP) === "0")
|
|
2915
2896
|
return;
|
|
2916
2897
|
const home = process.env.HOME || os.homedir();
|
|
2917
2898
|
const cfgPath = path.join(home, ".claude.json");
|
|
@@ -2958,11 +2939,11 @@ function ensureCoworkMcpRegistered() {
|
|
|
2958
2939
|
// `claude -p` steps route through the job queue (S4L_CLAUDE_PROVIDER=queue) for
|
|
2959
2940
|
// the scheduled-task workers to service. The per-cycle DRAFT_ONLY value is NOT
|
|
2960
2941
|
// baked here anymore: run-draft-and-publish.sh decides it from mode.json
|
|
2961
|
-
// (
|
|
2962
|
-
//
|
|
2963
|
-
// bar. The DRAFT_ONLY=1 below is only the safe baseline an OLD wrapper (which
|
|
2942
|
+
// (draft-only flag, 2026-07-06) — while draft-only is ON (default) cycles stop
|
|
2943
|
+
// before posting and merge into review cards; with draft-only OFF (operator
|
|
2944
|
+
// opt-out) promotion cycles post autonomously behind the virality bar. The DRAFT_ONLY=1 below is only the safe baseline an OLD wrapper (which
|
|
2964
2945
|
// never overrides it) inherits.
|
|
2965
|
-
// 60s tick (2026-07-06): parity with the retired claude -p
|
|
2946
|
+
// 60s tick (2026-07-06): parity with the retired claude -p twitter-cycle plist.
|
|
2966
2947
|
// The preflight slot gate (max-1) makes this safe — a tick that fires while a
|
|
2967
2948
|
// cycle is still running skips in milliseconds — so the effective cadence is
|
|
2968
2949
|
// back-to-back ~10-min cycles with ≤1 min idle, not one cycle per minute.
|
|
@@ -2971,23 +2952,19 @@ function kickerEnv() {
|
|
|
2971
2952
|
return {
|
|
2972
2953
|
DRAFT_ONLY: "1",
|
|
2973
2954
|
S4L_CLAUDE_PROVIDER: "queue",
|
|
2974
|
-
S4L_STATE_DIR:
|
|
2955
|
+
S4L_STATE_DIR: s4lStateDir(),
|
|
2975
2956
|
TWITTER_PAGE_GEN_RATE: "0",
|
|
2976
2957
|
// Thread-media context for the drafter (2026-07-06): parity with the
|
|
2977
|
-
// retired CLI
|
|
2958
|
+
// retired CLI twitter-cycle plist. Without it the prep step drafts blind to
|
|
2978
2959
|
// images in the candidate threads.
|
|
2979
2960
|
S4L_TWITTER_CAPTURE_MEDIA: "1",
|
|
2980
2961
|
// NOTE: TWITTER_TAIL_LINK_RATE is deliberately NOT set here (2026-07-06).
|
|
2981
|
-
// The persona lane exports =0 per cycle via
|
|
2962
|
+
// The persona lane exports =0 per cycle via s4l_mode.py env (link-free
|
|
2982
2963
|
// organic replies); promotion cycles keep the script's own default so
|
|
2983
|
-
//
|
|
2964
|
+
// draft-only-OFF posts carry the project link per the A/B gate, and card posts
|
|
2984
2965
|
// still force =1.0 inside post_drafts.
|
|
2985
|
-
//
|
|
2986
|
-
//
|
|
2987
|
-
// autopilot mode never post. p0.95 (not the script's 0.97 default) keeps
|
|
2988
|
-
// volume useful while cutting bottom-of-pool drafts; cold start
|
|
2989
|
-
// (sample_count < 200) still shows brand-new installs every draft.
|
|
2990
|
-
S4L_TWITTER_VIRALITY_PCTILE: "0.95",
|
|
2966
|
+
// Virality bar percentile is NOT set here: it is hardcoded to 0.90 in
|
|
2967
|
+
// skill/run-twitter-cycle.sh (single source of truth, no env dependency).
|
|
2991
2968
|
};
|
|
2992
2969
|
}
|
|
2993
2970
|
async function ensureQueueKickerInstalled() {
|
|
@@ -3073,7 +3050,7 @@ async function ensureQueueKickerInstalled() {
|
|
|
3073
3050
|
// every later cycle runs the standard 24h + top-1 logic. Best-effort: a
|
|
3074
3051
|
// failed write just means a standard first cycle.
|
|
3075
3052
|
try {
|
|
3076
|
-
const stateDir =
|
|
3053
|
+
const stateDir = s4lStateDir();
|
|
3077
3054
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
3078
3055
|
fs.writeFileSync(path.join(stateDir, "first-run-boost.json"), JSON.stringify({ created_at: new Date().toISOString() }) + "\n", "utf-8");
|
|
3079
3056
|
}
|
|
@@ -3205,7 +3182,7 @@ async function ensureStallWatchInstalled() {
|
|
|
3205
3182
|
try {
|
|
3206
3183
|
if (process.platform !== "darwin")
|
|
3207
3184
|
return { ok: false, detail: "not macOS" };
|
|
3208
|
-
if ((process.env.S4L_STALL_WATCH
|
|
3185
|
+
if ((process.env.S4L_STALL_WATCH) === "0")
|
|
3209
3186
|
return { ok: false, detail: "disabled (S4L_STALL_WATCH=0)" };
|
|
3210
3187
|
const logDir = path.join(repoDir(), "skill", "logs");
|
|
3211
3188
|
try {
|
|
@@ -3254,7 +3231,7 @@ async function ensureMemorySnapshotInstalled() {
|
|
|
3254
3231
|
try {
|
|
3255
3232
|
if (process.platform !== "darwin")
|
|
3256
3233
|
return { ok: false, detail: "not macOS" };
|
|
3257
|
-
if ((process.env.S4L_MEMORY_SNAPSHOT
|
|
3234
|
+
if ((process.env.S4L_MEMORY_SNAPSHOT) === "0")
|
|
3258
3235
|
return { ok: false, detail: "disabled (S4L_MEMORY_SNAPSHOT=0)" };
|
|
3259
3236
|
const logDir = path.join(repoDir(), "skill", "logs");
|
|
3260
3237
|
try {
|
|
@@ -3316,7 +3293,7 @@ async function ensureOverlayWatchInstalled() {
|
|
|
3316
3293
|
try {
|
|
3317
3294
|
if (process.platform !== "darwin")
|
|
3318
3295
|
return { ok: false, detail: "not macOS" };
|
|
3319
|
-
if ((process.env.S4L_OVERLAY_WATCH
|
|
3296
|
+
if ((process.env.S4L_OVERLAY_WATCH) === "0")
|
|
3320
3297
|
return { ok: false, detail: "disabled (S4L_OVERLAY_WATCH=0)" };
|
|
3321
3298
|
const logDir = path.join(repoDir(), "skill", "logs");
|
|
3322
3299
|
try {
|
|
@@ -3501,7 +3478,7 @@ async function healTopicsSeeded(snap) {
|
|
|
3501
3478
|
// ---- dashboard localhost fallback -----------------------------------------
|
|
3502
3479
|
// When the connected host doesn't support MCP Apps UI (Claude Code / Cowork
|
|
3503
3480
|
// today), serve the SAME dist/panel.html from a loopback HTTP server. The page
|
|
3504
|
-
// detects it's running over HTTP (window.
|
|
3481
|
+
// detects it's running over HTTP (window.__S4L_BRIDGE__) and routes every
|
|
3505
3482
|
// app.callServerTool through POST /tool/<name>, which replays the exact captured
|
|
3506
3483
|
// handler in TOOL_HANDLERS. No pipeline or front-end logic is duplicated.
|
|
3507
3484
|
// True if the host advertised it can render our ui:// HTML resource inline.
|
|
@@ -3521,7 +3498,7 @@ let localPanel = null;
|
|
|
3521
3498
|
// minus the postMessage host (there's none over loopback).
|
|
3522
3499
|
function widgetHtmlForHttp(file) {
|
|
3523
3500
|
const html = fs.readFileSync(path.join(DIST_DIR, file), "utf-8");
|
|
3524
|
-
const inject = `<script>window.
|
|
3501
|
+
const inject = `<script>window.__S4L_BRIDGE__=${JSON.stringify("http")};</script>`;
|
|
3525
3502
|
if (html.includes("</head>"))
|
|
3526
3503
|
return html.replace("</head>", inject + "</head>");
|
|
3527
3504
|
return inject + html;
|
|
@@ -3607,7 +3584,7 @@ function startLocalPanel() {
|
|
|
3607
3584
|
srv.on("error", reject);
|
|
3608
3585
|
// Optional fixed port (S4L_PANEL_PORT) for deterministic addressing; default
|
|
3609
3586
|
// is an OS-assigned ephemeral port.
|
|
3610
|
-
const wantPort = Number(process.env.S4L_PANEL_PORT
|
|
3587
|
+
const wantPort = Number(process.env.S4L_PANEL_PORT) || 0;
|
|
3611
3588
|
srv.listen(wantPort, "127.0.0.1", () => {
|
|
3612
3589
|
const addr = srv.address();
|
|
3613
3590
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
@@ -3635,29 +3612,28 @@ function writePanelUrl(url) {
|
|
|
3635
3612
|
}
|
|
3636
3613
|
}
|
|
3637
3614
|
// The owned state dir, honoring S4L_STATE_DIR (matches menubar/s4l_state.py).
|
|
3638
|
-
function
|
|
3615
|
+
function s4lStateDir() {
|
|
3639
3616
|
return (process.env.S4L_STATE_DIR ||
|
|
3640
|
-
process.env.SAPS_STATE_DIR ||
|
|
3641
3617
|
path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp"));
|
|
3642
3618
|
}
|
|
3643
3619
|
// Has the user explicitly chosen an engagement mode? mode.json is written by the
|
|
3644
3620
|
// engagement_mode tool (setup) and the menu-bar toggle. Used to complete the
|
|
3645
|
-
// mode_chosen onboarding milestone. (Source of truth: scripts/
|
|
3621
|
+
// mode_chosen onboarding milestone. (Source of truth: scripts/s4l_mode.py.)
|
|
3646
3622
|
function modeChosen() {
|
|
3647
3623
|
try {
|
|
3648
|
-
return fs.existsSync(path.join(
|
|
3624
|
+
return fs.existsSync(path.join(s4lStateDir(), "mode.json"));
|
|
3649
3625
|
}
|
|
3650
3626
|
catch {
|
|
3651
3627
|
return false;
|
|
3652
3628
|
}
|
|
3653
3629
|
}
|
|
3654
3630
|
// The current engagement lane flags, surfaced in the snapshot so the dashboard
|
|
3655
|
-
// AND menu bar read them from ONE place (mode.json, the same file
|
|
3656
|
-
// writes). Mirrors
|
|
3631
|
+
// AND menu bar read them from ONE place (mode.json, the same file s4l_mode.py
|
|
3632
|
+
// writes). Mirrors s4l_mode.py get_flags(): explicit flag keys win; else map a
|
|
3657
3633
|
// legacy {"mode": ...} string; else default personal ON / promotion OFF.
|
|
3658
3634
|
function currentFlags() {
|
|
3659
3635
|
try {
|
|
3660
|
-
const d = JSON.parse(fs.readFileSync(path.join(
|
|
3636
|
+
const d = JSON.parse(fs.readFileSync(path.join(s4lStateDir(), "mode.json"), "utf-8"));
|
|
3661
3637
|
if ("personal_brand" in d || "promotion" in d) {
|
|
3662
3638
|
return { personal_brand: !!d.personal_brand, promotion: !!d.promotion };
|
|
3663
3639
|
}
|
|
@@ -3688,19 +3664,30 @@ function currentMode() {
|
|
|
3688
3664
|
const POSTING_FLAG_TTL_MS = 45_000;
|
|
3689
3665
|
let postingFlagHeartbeat = null;
|
|
3690
3666
|
function postingFlagPath() {
|
|
3691
|
-
return path.join(
|
|
3667
|
+
return path.join(s4lStateDir(), "posting-active.json");
|
|
3692
3668
|
}
|
|
3693
3669
|
function writePostingFlag() {
|
|
3694
3670
|
try {
|
|
3695
|
-
fs.mkdirSync(
|
|
3671
|
+
fs.mkdirSync(s4lStateDir(), { recursive: true });
|
|
3696
3672
|
fs.writeFileSync(postingFlagPath(), JSON.stringify({ pid: process.pid, expires_at: Date.now() + POSTING_FLAG_TTL_MS }) + "\n", "utf-8");
|
|
3697
3673
|
}
|
|
3698
|
-
catch {
|
|
3699
|
-
|
|
3674
|
+
catch (e) {
|
|
3675
|
+
// The 2026-06-23 saga flagged "posting-active.json never writes" as open,
|
|
3676
|
+
// and this silent catch is why nobody could tell. Say it, both places.
|
|
3677
|
+
console.error(`[post] posting-active flag write FAILED: ${String(e)}`);
|
|
3678
|
+
logPostEvent(`posting_flag_write_failed err=${String(e)}`);
|
|
3700
3679
|
}
|
|
3701
3680
|
}
|
|
3702
3681
|
function startPostingFlagHeartbeat() {
|
|
3703
3682
|
writePostingFlag();
|
|
3683
|
+
try {
|
|
3684
|
+
if (!fs.existsSync(postingFlagPath())) {
|
|
3685
|
+
logPostEvent(`posting_flag_missing_after_write path=${postingFlagPath()}`);
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
catch {
|
|
3689
|
+
/* best effort */
|
|
3690
|
+
}
|
|
3704
3691
|
if (postingFlagHeartbeat)
|
|
3705
3692
|
return;
|
|
3706
3693
|
// Refresh well within the TTL so a long batch stays flagged, but a dead poster
|
|
@@ -3742,7 +3729,7 @@ let _activityLast = null;
|
|
|
3742
3729
|
let _activityHb = null;
|
|
3743
3730
|
function _writeActivityFile(state, label) {
|
|
3744
3731
|
try {
|
|
3745
|
-
const dir =
|
|
3732
|
+
const dir = s4lStateDir();
|
|
3746
3733
|
fs.mkdirSync(dir, { recursive: true });
|
|
3747
3734
|
fs.writeFileSync(path.join(dir, "activity.json"), JSON.stringify({ state, label, since: new Date().toISOString() }) + "\n", "utf-8");
|
|
3748
3735
|
}
|
|
@@ -3776,7 +3763,7 @@ function clearActivity() {
|
|
|
3776
3763
|
_activityHb = null;
|
|
3777
3764
|
}
|
|
3778
3765
|
try {
|
|
3779
|
-
fs.rmSync(path.join(
|
|
3766
|
+
fs.rmSync(path.join(s4lStateDir(), "activity.json"), { force: true });
|
|
3780
3767
|
}
|
|
3781
3768
|
catch {
|
|
3782
3769
|
/* best effort */
|
|
@@ -3789,7 +3776,7 @@ function clearActivity() {
|
|
|
3789
3776
|
// Written atomically so a 1s poll never sees a half-written file.
|
|
3790
3777
|
function persistStatusSummary(snap) {
|
|
3791
3778
|
try {
|
|
3792
|
-
const dir =
|
|
3779
|
+
const dir = s4lStateDir();
|
|
3793
3780
|
fs.mkdirSync(dir, { recursive: true });
|
|
3794
3781
|
const tmp = path.join(dir, `status-summary.json.${process.pid}.tmp`);
|
|
3795
3782
|
fs.writeFileSync(tmp, JSON.stringify({ ...snap, written_at: new Date().toISOString() }) + "\n", "utf-8");
|
|
@@ -3807,7 +3794,7 @@ function persistStatusSummary(snap) {
|
|
|
3807
3794
|
// just means no pop-ups this batch (chat review still works).
|
|
3808
3795
|
function writeReviewRequest(req) {
|
|
3809
3796
|
try {
|
|
3810
|
-
const dir =
|
|
3797
|
+
const dir = s4lStateDir();
|
|
3811
3798
|
fs.mkdirSync(dir, { recursive: true });
|
|
3812
3799
|
fs.writeFileSync(path.join(dir, "review-request.json"), JSON.stringify(req, null, 2) + "\n", "utf-8");
|
|
3813
3800
|
}
|
|
@@ -3822,7 +3809,7 @@ function writeReviewRequest(req) {
|
|
|
3822
3809
|
// S4L_PANEL_OPEN_BROWSER=1 to restore the old auto-open behavior. (The URL is
|
|
3823
3810
|
// always returned to the caller regardless, so nothing is lost when we don't open.)
|
|
3824
3811
|
async function openInBrowser(url) {
|
|
3825
|
-
if (!(process.env.S4L_PANEL_OPEN_BROWSER
|
|
3812
|
+
if (!(process.env.S4L_PANEL_OPEN_BROWSER))
|
|
3826
3813
|
return;
|
|
3827
3814
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
3828
3815
|
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
@@ -3904,7 +3891,23 @@ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
3904
3891
|
// autopilot tick stacks another on top (the zombie pileup that stale-reclaimed the
|
|
3905
3892
|
// lock mid-post). SIGKILL can't be trapped. Kill children first so the harness CDP
|
|
3906
3893
|
// driver lets go of Chrome immediately.
|
|
3894
|
+
// Timestamped on-disk trail for every scan preemption and posting-flag failure.
|
|
3895
|
+
// The console.error lines land in Claude Desktop's per-profile MCP log, which has
|
|
3896
|
+
// proven hard to even LOCATE during incidents (2026-07-06 forensics had to infer
|
|
3897
|
+
// two of six scan kills from bash job-control lines). This file lives next to the
|
|
3898
|
+
// post-*.log dumps so one directory tells the whole posting story. Best-effort.
|
|
3899
|
+
function logPostEvent(msg) {
|
|
3900
|
+
try {
|
|
3901
|
+
const dir = path.join(repoDir(), "skill", "logs");
|
|
3902
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3903
|
+
fs.appendFileSync(path.join(dir, "post-preempt-events.log"), `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
|
|
3904
|
+
}
|
|
3905
|
+
catch {
|
|
3906
|
+
/* best effort */
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3907
3909
|
function sigkillScanTree(pid) {
|
|
3910
|
+
logPostEvent(`sigkill_scan_tree target=${pid}`);
|
|
3908
3911
|
try {
|
|
3909
3912
|
const out = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf-8", timeout: 4000 });
|
|
3910
3913
|
for (const cstr of out.split(/\s+/)) {
|
|
@@ -3957,7 +3960,7 @@ function sigkillAllScans() {
|
|
|
3957
3960
|
// BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
|
|
3958
3961
|
// keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
|
|
3959
3962
|
// hold EXPANDS as more cards get approved and there is never a gap between cards.
|
|
3960
|
-
const SHELL_LOCK_GRACE_MS = Number(process.env.S4L_POST_LOCK_GRACE_MS
|
|
3963
|
+
const SHELL_LOCK_GRACE_MS = Number(process.env.S4L_POST_LOCK_GRACE_MS) || 60_000;
|
|
3961
3964
|
let shellLockReleaseTimer = null;
|
|
3962
3965
|
// True from the start of a post batch until SHELL_LOCK_GRACE_MS after the last
|
|
3963
3966
|
// card. The draft-cycle scan checks this and DEFERS launching a scan while it's set —
|
|
@@ -3990,6 +3993,7 @@ function preemptScanHoldingBrowser() {
|
|
|
3990
3993
|
const pid = shellLockHolderPid();
|
|
3991
3994
|
if (pid && pidAlive(pid) && pidIsScan(pid)) {
|
|
3992
3995
|
console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid}) — SIGKILL tree`);
|
|
3996
|
+
logPostEvent(`preempt_scan_holding_browser scan_pid=${pid}`);
|
|
3993
3997
|
sigkillScanTree(pid);
|
|
3994
3998
|
}
|
|
3995
3999
|
}
|
|
@@ -4034,6 +4038,7 @@ async function acquireShellBrowserLock() {
|
|
|
4034
4038
|
rmShellLockDir();
|
|
4035
4039
|
}
|
|
4036
4040
|
else if (pidIsScan(pid)) {
|
|
4041
|
+
logPostEvent(`preempt_scan_on_lock_acquire scan_pid=${pid} attempt=${attempt}`);
|
|
4037
4042
|
sigkillScanTree(pid); // SIGKILL — scans trap SIGTERM and survive it
|
|
4038
4043
|
await sleepMs(300);
|
|
4039
4044
|
rmShellLockDir();
|
|
@@ -4443,6 +4448,32 @@ async function main() {
|
|
|
4443
4448
|
const tr = setInterval(relayTranscripts, 5 * 60_000);
|
|
4444
4449
|
tr.unref();
|
|
4445
4450
|
}
|
|
4451
|
+
// Relay the queue producer/consumer log (claude-queue/provider.log) so a
|
|
4452
|
+
// stranded/orphaned draft batch — a producer that died after its worker wrote
|
|
4453
|
+
// the result but before consuming it — is visible in Cloud Logging remotely,
|
|
4454
|
+
// not just on the box (context="queue-provider"). Incremental (byte offset),
|
|
4455
|
+
// self-locking, forward-only baseline. Best-effort; opt out S4L_PROVIDER_LOG_RELAY=0.
|
|
4456
|
+
if ((process.env.S4L_PROVIDER_LOG_RELAY ?? "1") !== "0") {
|
|
4457
|
+
let providerRelayRunning = false;
|
|
4458
|
+
const relayProviderLog = () => {
|
|
4459
|
+
if (providerRelayRunning)
|
|
4460
|
+
return;
|
|
4461
|
+
providerRelayRunning = true;
|
|
4462
|
+
runPython("scripts/relay_provider_log.py", ["--max-lines", "500"], {
|
|
4463
|
+
timeoutMs: 120_000,
|
|
4464
|
+
})
|
|
4465
|
+
.catch((e) => {
|
|
4466
|
+
console.error("[social-autoposter-mcp] provider-log relay failed:", e?.message || e);
|
|
4467
|
+
})
|
|
4468
|
+
.finally(() => {
|
|
4469
|
+
providerRelayRunning = false;
|
|
4470
|
+
});
|
|
4471
|
+
};
|
|
4472
|
+
const prBoot = setTimeout(relayProviderLog, 95_000); // off the boot hot path
|
|
4473
|
+
prBoot.unref();
|
|
4474
|
+
const pr = setInterval(relayProviderLog, 5 * 60_000);
|
|
4475
|
+
pr.unref();
|
|
4476
|
+
}
|
|
4446
4477
|
// Sync the install's configuration state (config.json, persona corpus, mode,
|
|
4447
4478
|
// queues, onboarding ledger) to the backend. Hash-gated on the interval, so
|
|
4448
4479
|
// the recurring tick only POSTs when something actually changed; setup.ts
|