@m13v/s4l 1.7.1-rc.9 → 1.7.2-rc.1
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 +364 -24
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +554 -81
- package/mcp/menubar/s4l_menubar.py +382 -177
- package/mcp/menubar/s4l_state.py +228 -33
- package/mcp/package.json +1 -1
- package/package.json +4 -1
- package/scripts/active_users.py +3 -6
- package/scripts/autopilot_stall_watch.py +200 -16
- package/scripts/claude_job.py +36 -0
- package/scripts/feedback_digest.py +34 -6
- package/scripts/harness_overlay.py +45 -0
- package/scripts/identity.py +26 -0
- package/scripts/learned_preferences.py +241 -32
- package/scripts/link_tail.py +167 -91
- package/scripts/mark_event.py +128 -0
- package/scripts/memory_snapshot.py +27 -0
- package/scripts/merge_review_queue.py +42 -16
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/producer_deathwatch.py +210 -0
- package/scripts/run_claude.sh +27 -0
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scan_pii.py +9 -1
- package/scripts/schedule_state.py +146 -69
- package/scripts/scheduled_task_selfheal.py +302 -0
- package/scripts/scheduled_tasks_snapshot.py +107 -0
- package/scripts/send_gmail_report.py +76 -0
- package/scripts/sentry_digest.py +81 -189
- package/scripts/sentry_init.py +43 -15
- package/scripts/snapshot.py +20 -0
- package/scripts/twitter_post_plan.py +39 -13
- package/skill/dm-outreach-twitter.sh +19 -0
- package/skill/engage-twitter.sh +27 -0
- package/skill/run-draft-and-publish.sh +13 -12
- package/skill/run-linkedin.sh +9 -1
- package/skill/run-twitter-cycle.sh +227 -57
- package/skill/sentry-digest.sh +125 -12
package/mcp/dist/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
|
|
|
25
25
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, menubarRunning, clearMenubarStop, ensurePipelineCurrent, ensureRuntimeProvisioned, retryProvisionIfStalled, } from "./runtime.js";
|
|
26
26
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
27
27
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
28
|
-
import { initSentry, sendHeartbeat, sendStateSnapshot, captureError, flushSentry, startLogStreaming, flushLogs, logLine } from "./telemetry.js";
|
|
28
|
+
import { initSentry, sendHeartbeat, sendStateSnapshot, captureError, captureMessage, flushSentry, startLogStreaming, flushLogs, logLine, checkVersionChange } from "./telemetry.js";
|
|
29
29
|
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import http from "node:http";
|
|
@@ -885,11 +885,24 @@ async function postApproved(batchId, plan) {
|
|
|
885
885
|
{
|
|
886
886
|
const gateDeadline = Date.now() + 8 * 60_000;
|
|
887
887
|
let waited = false;
|
|
888
|
+
let waitStartedAt = 0;
|
|
888
889
|
while ((postingActive || isPeerDrainActive()) && Date.now() < gateDeadline) {
|
|
890
|
+
if (!waited) {
|
|
891
|
+
waitStartedAt = Date.now();
|
|
892
|
+
// This loop was previously silent end-to-end (2026-07-08 forensics: a
|
|
893
|
+
// ~1:45 gap in a user's approval batch had zero trace anywhere). Log
|
|
894
|
+
// once on entry (not per 5s tick, to avoid spamming the file) so a
|
|
895
|
+
// later "why did card N take so long" question has a starting point.
|
|
896
|
+
logPostEvent(`postApproved_wait_start batch=${batchId} blocked_by=${describePostingBlocker()}`);
|
|
897
|
+
}
|
|
889
898
|
waited = true;
|
|
890
899
|
await sleepMs(5000);
|
|
891
900
|
}
|
|
892
|
-
|
|
901
|
+
const timedOut = postingActive || isPeerDrainActive();
|
|
902
|
+
if (waited) {
|
|
903
|
+
logPostEvent(`postApproved_wait_end batch=${batchId} waited_ms=${Date.now() - waitStartedAt} timed_out=${timedOut}`);
|
|
904
|
+
}
|
|
905
|
+
if (timedOut) {
|
|
893
906
|
return {
|
|
894
907
|
attempted: 0,
|
|
895
908
|
exit_code: 0,
|
|
@@ -1181,10 +1194,21 @@ async function postApproved(batchId, plan) {
|
|
|
1181
1194
|
}
|
|
1182
1195
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
1183
1196
|
// so they never reach Sentry on their own. Capture an explicit event whenever
|
|
1184
|
-
// the run exited non-zero OR
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
|
|
1197
|
+
// the run exited non-zero OR a REAL failure happened. This is the only
|
|
1198
|
+
// telemetry channel that reaches a customer .mcpb install (their cycle log
|
|
1199
|
+
// lives on their machine). install_id/hostname are auto-tagged.
|
|
1200
|
+
//
|
|
1201
|
+
// Gated on failure_reasons (not just realPosted < approved.length): the
|
|
1202
|
+
// Python pipeline already reports every shortfall to Sentry itself
|
|
1203
|
+
// (twitter_post_plan.py's capture_message), including benign skips like a
|
|
1204
|
+
// deleted target tweet. Re-reporting the SAME benign skip here as a second,
|
|
1205
|
+
// independently-fingerprinted `Error` (this capture group) doubled every
|
|
1206
|
+
// such event into two distinct Sentry issues. Only add this Node-side
|
|
1207
|
+
// capture when there's a real failure_reasons entry or a non-zero exit,
|
|
1208
|
+
// i.e. something the Python capture wouldn't already have flagged as an
|
|
1209
|
+
// actionable error on its own.
|
|
1210
|
+
const hasRealFailure = res.code !== 0 || Boolean(summObj?.failure_reasons);
|
|
1211
|
+
if (hasRealFailure) {
|
|
1188
1212
|
captureError(new Error(`post_drafts: ${realPosted}/${approved.length} posted (exit=${res.code})`), {
|
|
1189
1213
|
component: "post",
|
|
1190
1214
|
exit_code: String(res.code),
|
|
@@ -1594,7 +1618,10 @@ tool("engagement_mode", {
|
|
|
1594
1618
|
// persona is seeded. (2026-06-30) Skipped when promotion-only, since the
|
|
1595
1619
|
// product project isn't configured yet (it stays gated until project_config).
|
|
1596
1620
|
let kickerInstall = null;
|
|
1597
|
-
if (personalBrand) {
|
|
1621
|
+
if (personalBrand && isPaused()) {
|
|
1622
|
+
kickerInstall = { ok: false, detail: "skip (paused)" };
|
|
1623
|
+
}
|
|
1624
|
+
else if (personalBrand) {
|
|
1598
1625
|
try {
|
|
1599
1626
|
kickerInstall = await ensureQueueKickerInstalled();
|
|
1600
1627
|
console.error(`[engagement_mode] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
|
|
@@ -2088,7 +2115,10 @@ tool("project_config", {
|
|
|
2088
2115
|
// persona-aware, so calling it from both setup paths is safe. Best-effort:
|
|
2089
2116
|
// a kicker hiccup never fails setup. (2026-06-30)
|
|
2090
2117
|
let kickerInstall = null;
|
|
2091
|
-
if (result.ready) {
|
|
2118
|
+
if (result.ready && isPaused()) {
|
|
2119
|
+
kickerInstall = { ok: false, detail: "skip (paused)" };
|
|
2120
|
+
}
|
|
2121
|
+
else if (result.ready) {
|
|
2092
2122
|
try {
|
|
2093
2123
|
kickerInstall = await ensureQueueKickerInstalled();
|
|
2094
2124
|
console.error(`[project_config] launchd kicker: ${kickerInstall.ok ? "ok" : "skip"} (${kickerInstall.detail})`);
|
|
@@ -2166,7 +2196,16 @@ tool("post_drafts", {
|
|
|
2166
2196
|
.optional()
|
|
2167
2197
|
.describe("1-based draft numbers to post as drafted, e.g. [1, 3, 5]."),
|
|
2168
2198
|
edits: z
|
|
2169
|
-
.array(z.object({
|
|
2199
|
+
.array(z.object({
|
|
2200
|
+
n: z.number().int().positive(),
|
|
2201
|
+
text: z.string(),
|
|
2202
|
+
variant: z
|
|
2203
|
+
.enum(["a", "b"])
|
|
2204
|
+
.optional()
|
|
2205
|
+
.describe("Two-draft cards only: which of candidate n's `drafts` entries this text came " +
|
|
2206
|
+
"from, so the candidate's style/assigned_style/assigned_mode are switched to " +
|
|
2207
|
+
"match (otherwise engagement_style stats would be attributed to the wrong style)."),
|
|
2208
|
+
}))
|
|
2170
2209
|
.optional()
|
|
2171
2210
|
.describe("Rewrites: each {n, text} replaces draft n's wording, then posts it."),
|
|
2172
2211
|
post_all: z.boolean().optional().describe("Post every draft in the batch."),
|
|
@@ -2222,7 +2261,22 @@ tool("post_drafts", {
|
|
|
2222
2261
|
warnings.push(`ignored empty edit for #${e.n}`);
|
|
2223
2262
|
return;
|
|
2224
2263
|
}
|
|
2225
|
-
candidates[e.n - 1]
|
|
2264
|
+
const c = candidates[e.n - 1];
|
|
2265
|
+
c.reply_text = text;
|
|
2266
|
+
// Two-draft cards: the human switched to the OTHER draft (with or
|
|
2267
|
+
// without further hand-editing its text). Carry that draft's own
|
|
2268
|
+
// style/assigned_style/assigned_mode onto the candidate so
|
|
2269
|
+
// twitter_post_plan.py's per-candidate drift-coercion posts under (and
|
|
2270
|
+
// logs) the style that's ACTUALLY posting, not whichever draft was
|
|
2271
|
+
// recommended at plan-write time.
|
|
2272
|
+
if (e.variant && Array.isArray(c.drafts)) {
|
|
2273
|
+
const chosen = c.drafts.find((d) => d.variant === e.variant);
|
|
2274
|
+
if (chosen) {
|
|
2275
|
+
c.engagement_style = chosen.style ?? c.engagement_style;
|
|
2276
|
+
c.assigned_style = chosen.assigned_style ?? null;
|
|
2277
|
+
c.assigned_mode = chosen.assigned_mode ?? c.assigned_mode;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2226
2280
|
approve.add(e.n);
|
|
2227
2281
|
editedCount++;
|
|
2228
2282
|
});
|
|
@@ -2484,6 +2538,7 @@ tool("runtime", {
|
|
|
2484
2538
|
return jsonContent({
|
|
2485
2539
|
...snapshot,
|
|
2486
2540
|
menubar_running: await menubarRunning(),
|
|
2541
|
+
paused: isPaused(),
|
|
2487
2542
|
onboarding: onboardingSnapshot(),
|
|
2488
2543
|
});
|
|
2489
2544
|
});
|
|
@@ -2514,6 +2569,37 @@ tool("restart_menubar", {
|
|
|
2514
2569
|
menubar_running: running,
|
|
2515
2570
|
});
|
|
2516
2571
|
});
|
|
2572
|
+
// ---- pause_s4l: temporarily stop drafting/posting, reversibly --------------
|
|
2573
|
+
// The lighter alternative to Quit: unloads only the launchd jobs that scan,
|
|
2574
|
+
// draft, and post (plus their support daemons), leaving Claude Desktop, the
|
|
2575
|
+
// S4L tray, X connection, and the draft schedule registration untouched.
|
|
2576
|
+
// Fully reversible — action:'resume' reinstalls the exact same daemons via the
|
|
2577
|
+
// same idempotent ensure*Installed() functions boot uses. NOT "autopilot": S4L
|
|
2578
|
+
// is draft-first (a human approves every post), so this pauses/resumes the
|
|
2579
|
+
// draft pipeline itself, not some autonomous posting mode.
|
|
2580
|
+
tool("pause_s4l", {
|
|
2581
|
+
title: "Pause or resume S4L",
|
|
2582
|
+
description: "Pause temporarily stops S4L's own draft pipeline (the launchd kicker that scans/drafts/posts, " +
|
|
2583
|
+
"plus its reaper/stall-watch/memory-snapshot support jobs) WITHOUT touching Claude Desktop, the " +
|
|
2584
|
+
"S4L tray, your X connection, or the draft schedule registration — nothing is deleted, so it " +
|
|
2585
|
+
"survives a Claude Desktop restart and Resume brings it right back. Use when the user asks to " +
|
|
2586
|
+
"pause, stop, or temporarily disable S4L, or to resume/unpause it. This is NOT the same as Quit " +
|
|
2587
|
+
"(which kills and restarts Claude Desktop and removes the draft schedule) — Pause is the lighter, " +
|
|
2588
|
+
"fully reversible option. action:'status' (default) reports whether it's currently paused.",
|
|
2589
|
+
inputSchema: {
|
|
2590
|
+
action: z.enum(["status", "pause", "resume"]).optional(),
|
|
2591
|
+
},
|
|
2592
|
+
}, async ({ action }) => {
|
|
2593
|
+
if (action === "pause") {
|
|
2594
|
+
const res = await pauseS4L();
|
|
2595
|
+
return jsonContent({ action: "pause", paused: isPaused(), ...res });
|
|
2596
|
+
}
|
|
2597
|
+
if (action === "resume") {
|
|
2598
|
+
const res = await resumeS4L();
|
|
2599
|
+
return jsonContent({ action: "resume", paused: isPaused(), ...res });
|
|
2600
|
+
}
|
|
2601
|
+
return jsonContent({ action: "status", paused: isPaused() });
|
|
2602
|
+
});
|
|
2517
2603
|
// ---- report_diagnosis: ship a field diagnosis to the developers -------------
|
|
2518
2604
|
// First-class MCP wrapper over scripts/send_diagnostic_report.py (the same
|
|
2519
2605
|
// Sentry lane the menubar "Diagnose & fix" prompt uses). Before this existed
|
|
@@ -2552,6 +2638,29 @@ tool("report_diagnosis", {
|
|
|
2552
2638
|
return jsonContent({ ok: false, detail: String(e?.message || e).slice(0, 300) });
|
|
2553
2639
|
}
|
|
2554
2640
|
});
|
|
2641
|
+
// ---- client_event: lightweight UI telemetry ping from the dashboard panel --
|
|
2642
|
+
// The panel iframe is a browser context with no Sentry SDK and no server-side
|
|
2643
|
+
// telemetry access of its own. Before this, a panel button click (e.g. "Set up
|
|
2644
|
+
// draft schedule") had NO record anywhere — report_diagnosis needs a full
|
|
2645
|
+
// markdown report from an agent turn, which doesn't fit a plain click, so the
|
|
2646
|
+
// panel's rearm button silently had zero telemetry while its menu-bar sibling
|
|
2647
|
+
// did (see s4l_menubar.py _capture_msg). Not for agent use: the panel calls
|
|
2648
|
+
// this directly via app.callServerTool, never through chat.
|
|
2649
|
+
tool("client_event", {
|
|
2650
|
+
title: "Log a lightweight client UI event",
|
|
2651
|
+
description: "Internal telemetry hook for the dashboard panel to report a UI event (e.g. a button click). " +
|
|
2652
|
+
"Not intended for the agent to call from chat.",
|
|
2653
|
+
inputSchema: {
|
|
2654
|
+
event: z.string().describe("Short event name, e.g. rearm_clicked"),
|
|
2655
|
+
surface: z.string().optional().describe("UI surface the event came from, e.g. panel"),
|
|
2656
|
+
},
|
|
2657
|
+
}, async ({ event, surface }) => {
|
|
2658
|
+
captureMessage(`S4L client event: ${event}`, {
|
|
2659
|
+
level: "info",
|
|
2660
|
+
tags: { component: "panel", event, surface: surface || "panel" },
|
|
2661
|
+
});
|
|
2662
|
+
return jsonContent({ ok: true });
|
|
2663
|
+
});
|
|
2555
2664
|
function runtimeSnapshot() {
|
|
2556
2665
|
const rt = readRuntime();
|
|
2557
2666
|
const progress = readProgress();
|
|
@@ -2728,6 +2837,16 @@ async function autopilotLoaded() {
|
|
|
2728
2837
|
// Claude turn, writes the result back, and stops.
|
|
2729
2838
|
// ===========================================================================
|
|
2730
2839
|
const QUEUE_WORKER_PROMPT_VERSION = 8; // v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. 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.
|
|
2840
|
+
// v9 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
|
|
2841
|
+
// sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
|
|
2842
|
+
// within one continuous worker session) instead of drafting inline. Validated
|
|
2843
|
+
// via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
|
|
2844
|
+
// delegated work, survives); the one hard constraint proven: the delegated
|
|
2845
|
+
// sub-agent must never fully idle-wait (e.g. background + wait on a Monitor
|
|
2846
|
+
// notification) or the host kills the whole parent+child chain in 1-3 min.
|
|
2847
|
+
// Never live-fire tested against a real production job. Full design, what's
|
|
2848
|
+
// validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
|
|
2849
|
+
// Bump this constant to 9 only once that plan is actually implemented.
|
|
2731
2850
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
2732
2851
|
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
2733
2852
|
// 240s (4 min): comfortably inside the 900s single-Bash-call survival verified
|
|
@@ -3553,6 +3672,76 @@ async function ensureMemorySnapshotInstalled() {
|
|
|
3553
3672
|
return { ok: false, detail: e?.message || String(e) };
|
|
3554
3673
|
}
|
|
3555
3674
|
}
|
|
3675
|
+
// ---- pause/resume: stop drafting/posting without touching Claude Desktop ---
|
|
3676
|
+
// Unlike Quit (which kills + relaunches Claude Desktop and deletes the draft
|
|
3677
|
+
// schedule), Pause only unloads the launchd jobs that actually DO work — the
|
|
3678
|
+
// kicker that scans/drafts/posts (TWITTER_AUTOPILOT_LABEL) plus its reaper,
|
|
3679
|
+
// stall-watch, and memory-snapshot support daemons — while leaving Claude
|
|
3680
|
+
// Desktop, the tray, and the Claude-native s4l-worker scheduled task alone.
|
|
3681
|
+
// The scheduled task still fires on its own cadence; claude_job.py's `next`
|
|
3682
|
+
// checks the same flag file and reports "no work" instantly instead of
|
|
3683
|
+
// draining the queue, so nothing drafts or posts while paused even if a
|
|
3684
|
+
// worker session wakes up mid-pause. A flag file (nothing is deleted) is what
|
|
3685
|
+
// makes Resume a plain reinstall of the same 4 daemons — see resumeS4L.
|
|
3686
|
+
function pauseFlagPath() {
|
|
3687
|
+
return path.join(s4lStateDir(), "paused.flag");
|
|
3688
|
+
}
|
|
3689
|
+
function isPaused() {
|
|
3690
|
+
try {
|
|
3691
|
+
return fs.existsSync(pauseFlagPath());
|
|
3692
|
+
}
|
|
3693
|
+
catch {
|
|
3694
|
+
return false;
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
const PAUSE_TARGETS = [
|
|
3698
|
+
{ label: TWITTER_AUTOPILOT_LABEL, plist: TWITTER_AUTOPILOT_PLIST },
|
|
3699
|
+
{ label: REAPER_LABEL, plist: REAPER_PLIST },
|
|
3700
|
+
{ label: STALL_WATCH_LABEL, plist: STALL_WATCH_PLIST },
|
|
3701
|
+
{ label: MEMORY_SNAPSHOT_LABEL, plist: MEMORY_SNAPSHOT_PLIST },
|
|
3702
|
+
];
|
|
3703
|
+
async function pauseS4L() {
|
|
3704
|
+
if (isPaused())
|
|
3705
|
+
return { ok: true, detail: "already paused" };
|
|
3706
|
+
try {
|
|
3707
|
+
fs.mkdirSync(path.dirname(pauseFlagPath()), { recursive: true });
|
|
3708
|
+
fs.writeFileSync(pauseFlagPath(), `paused at ${new Date().toISOString()}\n`, "utf-8");
|
|
3709
|
+
}
|
|
3710
|
+
catch (e) {
|
|
3711
|
+
return { ok: false, detail: `could not write pause flag: ${e?.message || e}` };
|
|
3712
|
+
}
|
|
3713
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
3714
|
+
const results = [];
|
|
3715
|
+
for (const { label, plist } of PAUSE_TARGETS) {
|
|
3716
|
+
try {
|
|
3717
|
+
const res = await unloadPlist(label, plist, uid);
|
|
3718
|
+
results.push(`${label}: unloaded (rc=${res.code})`);
|
|
3719
|
+
}
|
|
3720
|
+
catch (e) {
|
|
3721
|
+
results.push(`${label}: ${e?.message || e}`);
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
return { ok: true, detail: results.join("; ") };
|
|
3725
|
+
}
|
|
3726
|
+
async function resumeS4L() {
|
|
3727
|
+
try {
|
|
3728
|
+
fs.rmSync(pauseFlagPath(), { force: true });
|
|
3729
|
+
}
|
|
3730
|
+
catch {
|
|
3731
|
+
/* best-effort */
|
|
3732
|
+
}
|
|
3733
|
+
const kicker = await ensureQueueKickerInstalled();
|
|
3734
|
+
const reaper = await ensureClaudeReaperInstalled();
|
|
3735
|
+
const stall = await ensureStallWatchInstalled();
|
|
3736
|
+
const mem = await ensureMemorySnapshotInstalled();
|
|
3737
|
+
const detail = [
|
|
3738
|
+
`kicker: ${kicker.ok ? "ok" : "skip"} (${kicker.detail})`,
|
|
3739
|
+
`reaper: ${reaper.ok ? "ok" : "skip"} (${reaper.detail})`,
|
|
3740
|
+
`stall-watch: ${stall.ok ? "ok" : "skip"} (${stall.detail})`,
|
|
3741
|
+
`memory-snapshot: ${mem.ok ? "ok" : "skip"} (${mem.detail})`,
|
|
3742
|
+
].join("; ");
|
|
3743
|
+
return { ok: true, detail };
|
|
3744
|
+
}
|
|
3556
3745
|
// Install/refresh the on-screen overlay watcher launchd job. Promotes the
|
|
3557
3746
|
// harness status overlay from a best-effort, fired-from-other-tools nicety to a
|
|
3558
3747
|
// first-class self-healing job. We run `harness_overlay.py watch` directly in
|
|
@@ -3620,13 +3809,80 @@ async function ensureOverlayWatchInstalled() {
|
|
|
3620
3809
|
return { ok: false, detail: e?.message || String(e) };
|
|
3621
3810
|
}
|
|
3622
3811
|
}
|
|
3812
|
+
// Install/refresh the daily self-updater launchd job. This used to be bundled
|
|
3813
|
+
// into the now-deleted `autopilot` MCP tool (removed 2026-06-19, 88bd1cb9):
|
|
3814
|
+
// calling `autopilot enable` installed this job as a side effect, so removing
|
|
3815
|
+
// the tool silently orphaned it — UPDATER_LABEL/UPDATER_PLIST and the
|
|
3816
|
+
// auto_update_on status check survived (buildSnapshot still reports them), but
|
|
3817
|
+
// nothing has installed the plist since, on ANY box provisioned after that
|
|
3818
|
+
// commit (auto_update_on reads false forever). Restored here as its own
|
|
3819
|
+
// deterministic boot-time job, same pattern as its five siblings above.
|
|
3820
|
+
//
|
|
3821
|
+
// Points at scripts/s4l_box_update.sh, NOT skill/social-autoposter-update.sh:
|
|
3822
|
+
// the latter is the npm-lane updater (npm view + npx update) and is a silent
|
|
3823
|
+
// no-op on a .mcpb box, which has no npm/npx on PATH (see version.ts). The
|
|
3824
|
+
// .mcpb-lane equivalent downloads the .mcpb directly from the channel-resolved
|
|
3825
|
+
// GitHub release and unpacks it over the extension dir — see that script's own
|
|
3826
|
+
// header for the channel/no-downgrade/retry guards. Default mode there
|
|
3827
|
+
// downloads + unpacks + restarts Claude Desktop with NO human in the loop,
|
|
3828
|
+
// matching the original bundled updater's intent ("keeps a headless install
|
|
3829
|
+
// current"); RunAtLoad so a box that boots already-behind checks promptly.
|
|
3830
|
+
async function ensureUpdaterInstalled() {
|
|
3831
|
+
try {
|
|
3832
|
+
if (process.platform !== "darwin")
|
|
3833
|
+
return { ok: false, detail: "not macOS" };
|
|
3834
|
+
if ((process.env.S4L_AUTO_UPDATE) === "0")
|
|
3835
|
+
return { ok: false, detail: "disabled (S4L_AUTO_UPDATE=0)" };
|
|
3836
|
+
const logDir = path.join(repoDir(), "skill", "logs");
|
|
3837
|
+
try {
|
|
3838
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
3839
|
+
}
|
|
3840
|
+
catch {
|
|
3841
|
+
/* best-effort */
|
|
3842
|
+
}
|
|
3843
|
+
const xml = plistXml({
|
|
3844
|
+
label: UPDATER_LABEL,
|
|
3845
|
+
programArgs: ["/bin/bash", path.join(repoDir(), "scripts", "s4l_box_update.sh")],
|
|
3846
|
+
intervalSecs: 86_400,
|
|
3847
|
+
runAtLoad: true,
|
|
3848
|
+
stdoutLog: path.join(logDir, "launchd-self-update-stdout.log"),
|
|
3849
|
+
stderrLog: path.join(logDir, "launchd-self-update-stderr.log"),
|
|
3850
|
+
});
|
|
3851
|
+
const uid = process.getuid ? process.getuid() : 0;
|
|
3852
|
+
let cur = null;
|
|
3853
|
+
try {
|
|
3854
|
+
cur = fs.readFileSync(UPDATER_PLIST, "utf-8");
|
|
3855
|
+
}
|
|
3856
|
+
catch {
|
|
3857
|
+
cur = null;
|
|
3858
|
+
}
|
|
3859
|
+
let detail;
|
|
3860
|
+
if (cur === xml) {
|
|
3861
|
+
const res = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
3862
|
+
detail = `current (load rc=${res.code})`;
|
|
3863
|
+
}
|
|
3864
|
+
else {
|
|
3865
|
+
if (cur !== null) {
|
|
3866
|
+
await unloadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
3867
|
+
}
|
|
3868
|
+
fs.mkdirSync(path.dirname(UPDATER_PLIST), { recursive: true });
|
|
3869
|
+
fs.writeFileSync(UPDATER_PLIST, xml, "utf-8");
|
|
3870
|
+
const res = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
3871
|
+
detail = cur === null ? "installed + loaded" : `rewritten + reloaded (rc=${res.code})`;
|
|
3872
|
+
}
|
|
3873
|
+
return { ok: true, detail };
|
|
3874
|
+
}
|
|
3875
|
+
catch (e) {
|
|
3876
|
+
return { ok: false, detail: e?.message || String(e) };
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3623
3879
|
// Is the draft schedule registered AND running for the LIVE account?
|
|
3624
3880
|
// 'ok' — worker tasks present+enabled and FIRING (host actively running).
|
|
3625
3881
|
// 'disabled' — present but a worker task is disabled.
|
|
3626
3882
|
// 'missing' — not firing anywhere (orphaned / not registered for the live
|
|
3627
3883
|
// account) -> dashboard offers "Set up draft schedule".
|
|
3628
|
-
// The algorithm (live-account detection
|
|
3629
|
-
// etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
|
|
3884
|
+
// The algorithm (live-account detection via config.json's lastKnownAccountUuid,
|
|
3885
|
+
// firing window, etc.) lives in ONE place: scripts/schedule_state.py. The Python menu bar imports
|
|
3630
3886
|
// that module in-process; we shell out to it here. Keeping a single implementation
|
|
3631
3887
|
// is the whole point — the two surfaces can no longer drift. The script is
|
|
3632
3888
|
// stdlib-only and resolvePython() falls back to system python3, so this works even
|
|
@@ -3681,6 +3937,7 @@ async function buildSnapshot() {
|
|
|
3681
3937
|
// (it's a launchctl check the Node side owns), so layer it on here. The panel
|
|
3682
3938
|
// uses it to offer a one-click "restart menu bar" when the tray was quit.
|
|
3683
3939
|
snap.menubar_running = await menubarRunning();
|
|
3940
|
+
snap.paused = isPaused();
|
|
3684
3941
|
await ensureDoctorPhase(snap.x_connected ? "full" : "pre_connect");
|
|
3685
3942
|
if (snap.runtime_ready)
|
|
3686
3943
|
completeOnboardingMilestone("runtime_ready");
|
|
@@ -3921,8 +4178,12 @@ function writePanelUrl(url) {
|
|
|
3921
4178
|
// and fully usable via `url` for anything that already has it (e.g. this
|
|
3922
4179
|
// process's own Code side-panel proxy); we just don't publish ourselves
|
|
3923
4180
|
// as THE shared menu-bar target.
|
|
4181
|
+
logPanelEvent(`panel_cede existing_pid=${existing.pid}`);
|
|
3924
4182
|
return;
|
|
3925
4183
|
}
|
|
4184
|
+
logPanelEvent(existing?.pid
|
|
4185
|
+
? `panel_takeover dead_pid=${existing.pid}`
|
|
4186
|
+
: `panel_claim previous=none`);
|
|
3926
4187
|
fs.writeFileSync(panelEndpointPath(), JSON.stringify({ url, pid: process.pid, version: VERSION, started_at: new Date().toISOString() }, null, 2) + "\n", "utf-8");
|
|
3927
4188
|
}
|
|
3928
4189
|
catch (e) {
|
|
@@ -4073,6 +4334,25 @@ function isPeerDrainActive() {
|
|
|
4073
4334
|
return false;
|
|
4074
4335
|
}
|
|
4075
4336
|
}
|
|
4337
|
+
// Human-readable reason for the postApproved wait gate, used only for the
|
|
4338
|
+
// wait_start log line — distinguishes "our own drain hasn't hit its grace
|
|
4339
|
+
// release yet" from "a peer MCP instance (different pid, e.g. a separate
|
|
4340
|
+
// Claude session/queue-worker) is mid-drain," and names the peer pid + how
|
|
4341
|
+
// much longer its flag has left so a log reader doesn't have to guess.
|
|
4342
|
+
function describePostingBlocker() {
|
|
4343
|
+
if (postingActive)
|
|
4344
|
+
return "own_in_process_flag";
|
|
4345
|
+
try {
|
|
4346
|
+
const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
|
|
4347
|
+
if (typeof j?.expires_at === "number" && j.expires_at > Date.now() && typeof j?.pid === "number") {
|
|
4348
|
+
return `peer_pid=${j.pid} expires_in_ms=${j.expires_at - Date.now()}`;
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
catch {
|
|
4352
|
+
/* best effort */
|
|
4353
|
+
}
|
|
4354
|
+
return "unknown";
|
|
4355
|
+
}
|
|
4076
4356
|
// activity.json: a tiny "what's running right now" signal the menu bar reads to
|
|
4077
4357
|
// show a loading spinner + label (scanning / drafting / posting / …). Written by
|
|
4078
4358
|
// long-running tools, cleared when they finish. Best-effort; absence == idle.
|
|
@@ -4257,6 +4537,25 @@ function logPostEvent(msg) {
|
|
|
4257
4537
|
/* best effort */
|
|
4258
4538
|
}
|
|
4259
4539
|
}
|
|
4540
|
+
// Timestamped on-disk trail of panel-endpoint.json ownership handoffs. Mirrors
|
|
4541
|
+
// logPostEvent's rationale (per-process console.error is hard to even locate
|
|
4542
|
+
// across ephemeral, per-host-session MCP instances), for the panel-election
|
|
4543
|
+
// side instead of the posting side. writePanelUrl() runs on every process's
|
|
4544
|
+
// eager startup, so this also captures the mundane "claimed with nothing
|
|
4545
|
+
// registered" and "ceded to an already-alive owner" cases, not just handoffs
|
|
4546
|
+
// — that's what makes it possible to tell "a fresh instance took over from a
|
|
4547
|
+
// dead registrant" apart from "an approve click found nothing registered at
|
|
4548
|
+
// all" after the fact, instead of inferring it from timing alone.
|
|
4549
|
+
function logPanelEvent(msg) {
|
|
4550
|
+
try {
|
|
4551
|
+
const dir = path.join(repoDir(), "skill", "logs");
|
|
4552
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4553
|
+
fs.appendFileSync(path.join(dir, "panel-events.log"), `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
|
|
4554
|
+
}
|
|
4555
|
+
catch {
|
|
4556
|
+
/* best effort */
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
4260
4559
|
function sigkillScanTree(pid) {
|
|
4261
4560
|
logPostEvent(`sigkill_scan_tree target=${pid}`);
|
|
4262
4561
|
try {
|
|
@@ -4666,6 +4965,15 @@ async function drainApprovedBacklog() {
|
|
|
4666
4965
|
}
|
|
4667
4966
|
async function main() {
|
|
4668
4967
|
initSentry();
|
|
4968
|
+
// Detect a self-update (old_version -> new_version) as the very first thing
|
|
4969
|
+
// after Sentry is up, before anything else that could restart/exit. See
|
|
4970
|
+
// checkVersionChange's own docstring for why this exists.
|
|
4971
|
+
try {
|
|
4972
|
+
checkVersionChange();
|
|
4973
|
+
}
|
|
4974
|
+
catch (e) {
|
|
4975
|
+
console.error("[social-autoposter-mcp] version-change check failed:", e?.message || e);
|
|
4976
|
+
}
|
|
4669
4977
|
// Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
|
|
4670
4978
|
// Cloud Run relay (-> Cloud Logging) so we can troubleshoot/rescue any user
|
|
4671
4979
|
// scenario (silent stalls, partial onboarding) without asking them to ship a
|
|
@@ -4707,16 +5015,31 @@ async function main() {
|
|
|
4707
5015
|
catch (e) {
|
|
4708
5016
|
console.error(`[queue-worker] could not create worker folder: ${e?.message || e}`);
|
|
4709
5017
|
}
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
5018
|
+
// The 4 pipeline daemons (kicker, reaper, stall-watch, memory-snapshot) are
|
|
5019
|
+
// skipped at boot while paused.flag is present, so a Claude Desktop restart
|
|
5020
|
+
// during a Pause doesn't silently un-pause the pipeline — see pauseS4L/
|
|
5021
|
+
// resumeS4L. Feedback-digest is NOT gated: it only distills past review
|
|
5022
|
+
// decisions, not drafting/posting, so it's harmless to keep running.
|
|
5023
|
+
if (!isPaused()) {
|
|
5024
|
+
void ensureQueueKickerInstalled()
|
|
5025
|
+
.then((r) => console.error(`[queue-worker] launchd kicker: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5026
|
+
.catch((e) => console.error("[queue-worker] kicker install failed:", e?.message || e));
|
|
5027
|
+
}
|
|
5028
|
+
else {
|
|
5029
|
+
console.error("[queue-worker] launchd kicker: skip (paused)");
|
|
5030
|
+
}
|
|
4713
5031
|
// Self-healing reaper for the agent-mode session leak the queue autopilot
|
|
4714
5032
|
// produces (finished `claude` worker sessions Desktop never tears down). A
|
|
4715
5033
|
// standalone guardrail; install unconditionally so it caps memory even on a
|
|
4716
5034
|
// box whose project isn't ready yet. Best-effort; must never block boot.
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
5035
|
+
if (!isPaused()) {
|
|
5036
|
+
void ensureClaudeReaperInstalled()
|
|
5037
|
+
.then((r) => console.error(`[claude-reaper] launchd reaper: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5038
|
+
.catch((e) => console.error("[claude-reaper] reaper install failed:", e?.message || e));
|
|
5039
|
+
}
|
|
5040
|
+
else {
|
|
5041
|
+
console.error("[claude-reaper] launchd reaper: skip (paused)");
|
|
5042
|
+
}
|
|
4720
5043
|
// Feedback digest: hourly distillation of the user's card approve/reject
|
|
4721
5044
|
// decisions into learned_preferences (see scripts/feedback_digest.py).
|
|
4722
5045
|
// Best-effort; a box with no review events runs a no-op.
|
|
@@ -4726,15 +5049,25 @@ async function main() {
|
|
|
4726
5049
|
// Autopilot stall watchdog: fleet-side Sentry alert when the draft routines stop
|
|
4727
5050
|
// draining (most often an account switch orphaning them). The menu bar shows the
|
|
4728
5051
|
// user the Re-arm action; this is the part we see. Best-effort; never blocks boot.
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
5052
|
+
if (!isPaused()) {
|
|
5053
|
+
void ensureStallWatchInstalled()
|
|
5054
|
+
.then((r) => console.error(`[stall-watch] launchd watchdog: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5055
|
+
.catch((e) => console.error("[stall-watch] watchdog install failed:", e?.message || e));
|
|
5056
|
+
}
|
|
5057
|
+
else {
|
|
5058
|
+
console.error("[stall-watch] launchd watchdog: skip (paused)");
|
|
5059
|
+
}
|
|
4732
5060
|
// Periodic host-resource sampler (memory/process snapshot -> local JSONL). Gives
|
|
4733
5061
|
// us per-box resource history to diagnose RAM blowups (e.g. the agent-mode
|
|
4734
5062
|
// session leak). Best-effort; never blocks boot. Disable with S4L_MEMORY_SNAPSHOT=0.
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
5063
|
+
if (!isPaused()) {
|
|
5064
|
+
void ensureMemorySnapshotInstalled()
|
|
5065
|
+
.then((r) => console.error(`[memory-snapshot] launchd sampler: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5066
|
+
.catch((e) => console.error("[memory-snapshot] sampler install failed:", e?.message || e));
|
|
5067
|
+
}
|
|
5068
|
+
else {
|
|
5069
|
+
console.error("[memory-snapshot] launchd sampler: skip (paused)");
|
|
5070
|
+
}
|
|
4738
5071
|
// On-screen overlay watcher supervisor. The harness status overlay only renders
|
|
4739
5072
|
// while the watcher process is alive, and that watcher had no supervisor — when
|
|
4740
5073
|
// it died nothing respawned it and the overlay silently vanished. Install it as
|
|
@@ -4743,6 +5076,13 @@ async function main() {
|
|
|
4743
5076
|
void ensureOverlayWatchInstalled()
|
|
4744
5077
|
.then((r) => console.error(`[overlay-watch] launchd supervisor: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
4745
5078
|
.catch((e) => console.error("[overlay-watch] supervisor install failed:", e?.message || e));
|
|
5079
|
+
// Daily self-updater: restored 2026-07-08 after 88bd1cb9 ("Remove autopilot
|
|
5080
|
+
// tool") silently dropped its only install path (it used to be bundled into
|
|
5081
|
+
// the deleted `autopilot enable` action). Best-effort; never blocks boot.
|
|
5082
|
+
// Disable with S4L_AUTO_UPDATE=0.
|
|
5083
|
+
void ensureUpdaterInstalled()
|
|
5084
|
+
.then((r) => console.error(`[self-update] launchd updater: ${r.ok ? "ok" : "skip"} (${r.detail})`))
|
|
5085
|
+
.catch((e) => console.error("[self-update] updater install failed:", e?.message || e));
|
|
4746
5086
|
// Heal installs onboarded before short_links_live defaulted to false: such a
|
|
4747
5087
|
// project wraps short links against the customer's own domain, which has no
|
|
4748
5088
|
// /r/[code] resolver, so every minted link 404s. Re-point them at the s4l.ai
|