@m13v/s4l 1.6.203 → 1.6.204-rc.10
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 +202 -36
- package/mcp/dist/panel.html +27 -17
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/dashboard_server.py +39 -9
- package/mcp/menubar/s4l_card.py +79 -4
- package/mcp/menubar/s4l_menubar.py +124 -5
- package/mcp/menubar/s4l_state.py +55 -3
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/claude_job.py +30 -11
- package/scripts/feedback_digest.py +51 -37
- package/scripts/invent_topics.py +261 -223
- package/scripts/learned_preferences.py +111 -1
- package/scripts/preview_translated_card.py +30 -0
- package/scripts/reap_stale_claude_sessions.py +134 -0
- package/scripts/release-mcpb.sh +22 -5
- package/scripts/run_claude.sh +18 -15
- package/scripts/s4l_mode.py +87 -11
- package/scripts/send_diagnostic_report.py +74 -0
- package/scripts/snapshot.py +13 -0
- package/scripts/twitter_browser.py +126 -3
- package/scripts/twitter_post_plan.py +18 -5
- package/setup/SKILL.md +14 -6
- package/skill/invent-supply-test.sh +3 -1
- package/skill/invent-topics.sh +18 -12
- package/skill/run-draft-and-publish.sh +45 -1
- package/skill/run-twitter-cycle.sh +3 -1
- package/scripts/invent_mcp_server.py +0 -350
package/mcp/dist/index.js
CHANGED
|
@@ -42,13 +42,15 @@ const REVIEW_QUEUE_ID = "review-queue";
|
|
|
42
42
|
// ---- Queue-backed drafting (2026-06-23) -----------------------------------
|
|
43
43
|
// Customer .mcpb boxes have no `claude` CLI, so the deterministic pipeline can't
|
|
44
44
|
// run its `claude -p` steps directly. Instead a launchd job kicks the REAL
|
|
45
|
-
// pipeline (run-twitter-cycle.sh in DRAFT_ONLY mode
|
|
46
|
-
// queue
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
45
|
+
// pipeline (run-twitter-cycle.sh in DRAFT_ONLY mode); its `claude -p` steps
|
|
46
|
+
// carry queue-mapped script tags, so run_claude.sh routes each one onto
|
|
47
|
+
// scripts/claude_job.py's file queue and blocks (routing is by tag via
|
|
48
|
+
// claude_job.py TAG_TO_TYPE — the S4L_CLAUDE_PROVIDER env var was removed
|
|
49
|
+
// 2026-07-06). Claude Desktop scheduled tasks drain that queue, run the
|
|
50
|
+
// pipeline's own prompt as a Claude turn, and write the result back,
|
|
51
|
+
// unblocking the cycle. This reuses the entire pipeline (styles, voice,
|
|
50
52
|
// top-performers, em-dash rules). See scripts/claude_job.py + run_claude.sh's
|
|
51
|
-
//
|
|
53
|
+
// routing seam.
|
|
52
54
|
// Universal type-blind queue worker (2026-07-02): ONE scheduled task drains
|
|
53
55
|
// EVERY job type (`claude_job.py next --type any`). Per-type execution notes
|
|
54
56
|
// ride in the job's prompt sidecar (claude_job.py TYPE_TO_WORKER_NOTES), so
|
|
@@ -169,7 +171,7 @@ function plistXml(opts) {
|
|
|
169
171
|
const chromeEnv = chrome
|
|
170
172
|
? `\n\t\t<key>BH_CHROME_BIN</key>\n\t\t<string>${chrome}</string>`
|
|
171
173
|
: "";
|
|
172
|
-
// Caller-supplied env (e.g. the queue kicker's DRAFT_ONLY
|
|
174
|
+
// Caller-supplied env (e.g. the queue kicker's DRAFT_ONLY).
|
|
173
175
|
// Rendered after the baked-in vars so a caller can also override S4L_STATE_DIR.
|
|
174
176
|
const extraEnv = opts.extraEnv
|
|
175
177
|
? Object.entries(opts.extraEnv)
|
|
@@ -336,8 +338,11 @@ const server = new McpServer({
|
|
|
336
338
|
"onboarding does NOT need to wait for or verify a draft. Steps, in order: " +
|
|
337
339
|
"(1) call `queue_setup` — it pre-writes the worker task prompt and returns the task spec " +
|
|
338
340
|
"(s4l-worker, the universal queue worker); " +
|
|
339
|
-
"(2) for EACH returned spec, call the HOST tool `create_scheduled_task` with its taskId, cronExpression,
|
|
340
|
-
"prompt
|
|
341
|
+
"(2) for EACH returned spec, call the HOST tool `create_scheduled_task` with its taskId, cronExpression, " +
|
|
342
|
+
"prompt, and notifyOnCompletion VERBATIM — notifyOnCompletion MUST be false, or the per-minute worker " +
|
|
343
|
+
"pops a 'no jobs' notification every run (if it says the task already exists, that's fine — do not " +
|
|
344
|
+
"recreate, but DO call `update_scheduled_task` with the same taskId and notifyOnCompletion:false to " +
|
|
345
|
+
"silence an older install's per-run notifications, then move on); " +
|
|
341
346
|
"(3) the launchd kicker + scheduled tasks now run the pipeline on their own — call the `dashboard` " +
|
|
342
347
|
"tool to confirm the schedule is firing (schedule_state 'ok'), then summarize. Do NOT create the " +
|
|
343
348
|
"old 'social-autoposter-autopilot' task — it is deprecated. Do NOT ask the user to review drafts " +
|
|
@@ -861,6 +866,38 @@ async function ensureTwitterBrowserForPost() {
|
|
|
861
866
|
});
|
|
862
867
|
}
|
|
863
868
|
async function postApproved(batchId, plan) {
|
|
869
|
+
// Drain serialization (2026-07-06 incident). Every call drains the WHOLE
|
|
870
|
+
// approved backlog, so overlapping drains are pure waste and actively harmful:
|
|
871
|
+
// a Claude restart mid-drain at 00:25Z left 10 landed replies unstamped, then
|
|
872
|
+
// restart recovery + per-approval calls launched 8 concurrent approved=14
|
|
873
|
+
// drains that re-attempted already-replied threads, fought over the browser
|
|
874
|
+
// lock, and clobbered each other's posted stamps. Wait for any in-flight drain
|
|
875
|
+
// (ours via `postingActive`, a sibling MCP's via the posting flag on disk)
|
|
876
|
+
// instead of stacking a new one. 8-minute cap: comfortably above a normal
|
|
877
|
+
// drain, comfortably below the menu bar's 900s loopback timeout so a waiting
|
|
878
|
+
// call still returns to its caller. After a wait, RE-READ the plan so the
|
|
879
|
+
// peer's posted/terminal stamps shrink our backlog before we attempt anything.
|
|
880
|
+
{
|
|
881
|
+
const gateDeadline = Date.now() + 8 * 60_000;
|
|
882
|
+
let waited = false;
|
|
883
|
+
while ((postingActive || isPeerDrainActive()) && Date.now() < gateDeadline) {
|
|
884
|
+
waited = true;
|
|
885
|
+
await sleepMs(5000);
|
|
886
|
+
}
|
|
887
|
+
if (postingActive || isPeerDrainActive()) {
|
|
888
|
+
return {
|
|
889
|
+
attempted: 0,
|
|
890
|
+
exit_code: 0,
|
|
891
|
+
summary: "another posting drain has been running for 8+ minutes; approved cards stay " +
|
|
892
|
+
"queued in the review store — re-run post_drafts once it finishes",
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (waited) {
|
|
896
|
+
const fresh = readPlan(batchId);
|
|
897
|
+
if (fresh)
|
|
898
|
+
plan = fresh;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
864
901
|
// Post every card the user APPROVED that hasn't already landed or been ruled out.
|
|
865
902
|
// `approved` is now a DURABLE decision (sticky, never cleared by a later call), so
|
|
866
903
|
// filtering out posted/terminal here makes this idempotent: re-running it only
|
|
@@ -1063,10 +1100,52 @@ async function postApproved(batchId, plan) {
|
|
|
1063
1100
|
}
|
|
1064
1101
|
if (touchedPlan) {
|
|
1065
1102
|
try {
|
|
1066
|
-
|
|
1103
|
+
// Merge our stamps into a FRESH read of the store instead of rewriting the
|
|
1104
|
+
// whole plan from the copy we took minutes ago. The old whole-file write
|
|
1105
|
+
// was last-writer-wins: while this batch posted, the menubar (decision
|
|
1106
|
+
// re-stamps) and any peer drain also wrote the store, and whichever run
|
|
1107
|
+
// finished last erased the others' posted flags (2026-07-06: card 344877
|
|
1108
|
+
// posted at 00:29Z ended `posted=None, terminal=duplicate_thread_pre_post`
|
|
1109
|
+
// after a later run's stale write). Merge rules: `posted` is sticky and
|
|
1110
|
+
// wins over terminal; `terminal` never overwrites a fresh `posted=true`.
|
|
1111
|
+
// Fallback: candidates without a candidate_id can't be matched into the
|
|
1112
|
+
// fresh copy, so keep the legacy whole-plan write for those older plans.
|
|
1113
|
+
const mergeable = approved.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
1114
|
+
const fresh = mergeable ? readPlan(batchId) : null;
|
|
1115
|
+
if (fresh && Array.isArray(fresh.candidates)) {
|
|
1116
|
+
const freshById = new Map();
|
|
1117
|
+
fresh.candidates.forEach((c) => {
|
|
1118
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
1119
|
+
freshById.set(String(c.candidate_id), c);
|
|
1120
|
+
});
|
|
1121
|
+
for (const c of approved) {
|
|
1122
|
+
const f = freshById.get(String(c.candidate_id));
|
|
1123
|
+
if (!f)
|
|
1124
|
+
continue;
|
|
1125
|
+
if (c.posted === true) {
|
|
1126
|
+
f.posted = true;
|
|
1127
|
+
f.terminal = false;
|
|
1128
|
+
if (c.our_url)
|
|
1129
|
+
f.our_url = c.our_url;
|
|
1130
|
+
}
|
|
1131
|
+
else if (c.terminal === true && f.posted !== true) {
|
|
1132
|
+
f.terminal = true;
|
|
1133
|
+
f.terminal_reason = c.terminal_reason;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
writePlan(batchId, fresh);
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
writePlan(batchId, plan);
|
|
1140
|
+
}
|
|
1067
1141
|
}
|
|
1068
1142
|
catch {
|
|
1069
|
-
|
|
1143
|
+
try {
|
|
1144
|
+
writePlan(batchId, plan);
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
/* best effort */
|
|
1148
|
+
}
|
|
1070
1149
|
}
|
|
1071
1150
|
}
|
|
1072
1151
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
@@ -1241,7 +1320,8 @@ async function seedSearchQueriesForProject(project, rawQueries) {
|
|
|
1241
1320
|
tool("engagement_mode", {
|
|
1242
1321
|
title: "Choose engagement lanes (personal brand + optional product promotion)",
|
|
1243
1322
|
description: "Set or read the engagement LANES the autopilot drafts in. There are TWO independent lanes that " +
|
|
1244
|
-
"can BOTH be on (the cycle then splits
|
|
1323
|
+
"can BOTH be on (the cycle then splits per the configurable lane split, default 50/50 — see " +
|
|
1324
|
+
"action:'split'): PERSONAL BRAND (organic, link-free engagement in " +
|
|
1245
1325
|
"the user's own voice — ON by default) and PRODUCT PROMOTION (the marketing pipeline, link " +
|
|
1246
1326
|
"replies — OFF by default, opt-in). This is a SETUP step: AFTER X is connected, the profile " +
|
|
1247
1327
|
"is scanned (for VOICE), and the user has answered the DICTATION interview (for TOPICS + corpus), " +
|
|
@@ -1256,9 +1336,9 @@ tool("engagement_mode", {
|
|
|
1256
1336
|
"project_config afterward. The user flips either lane any time from the menu-bar checkmarks.",
|
|
1257
1337
|
inputSchema: {
|
|
1258
1338
|
action: z
|
|
1259
|
-
.enum(["get", "set", "toggle"])
|
|
1339
|
+
.enum(["get", "set", "toggle", "split"])
|
|
1260
1340
|
.optional()
|
|
1261
|
-
.describe("get = read current lane flags + persona status. set = record the user's chosen lanes (provisions the persona). toggle = lightweight flip of ONE lane (pass `lane`); mode.json only, no persona work — the dashboard/menu-bar quick toggle."),
|
|
1341
|
+
.describe("get = read current lane flags + persona status + lane split. set = record the user's chosen lanes (provisions the persona). toggle = lightweight flip of ONE lane (pass `lane`); mode.json only, no persona work — the dashboard/menu-bar quick toggle. split = set the personal-brand share of both-lanes-on cycles (pass `split`); mode.json only — the dashboard slider."),
|
|
1262
1342
|
personal_brand: z
|
|
1263
1343
|
.boolean()
|
|
1264
1344
|
.optional()
|
|
@@ -1271,6 +1351,12 @@ tool("engagement_mode", {
|
|
|
1271
1351
|
.enum(["personal_brand", "promotion"])
|
|
1272
1352
|
.optional()
|
|
1273
1353
|
.describe("action:'toggle' — which single lane to flip."),
|
|
1354
|
+
split: z
|
|
1355
|
+
.union([z.number(), z.string()])
|
|
1356
|
+
.optional()
|
|
1357
|
+
.describe("action:'split' (or alongside action:'set') — the personal-brand SHARE of cycles when both " +
|
|
1358
|
+
"lanes are on: 0.7, 70, or '70%' all mean 70% personal brand / 30% promotion. Clamped to " +
|
|
1359
|
+
"0..1; ignored while only one lane is on (single-lane states run that lane every cycle)."),
|
|
1274
1360
|
mode: z
|
|
1275
1361
|
.enum(["personal_brand", "promotion"])
|
|
1276
1362
|
.optional()
|
|
@@ -1320,11 +1406,47 @@ tool("engagement_mode", {
|
|
|
1320
1406
|
return { personal_brand: true, promotion: false };
|
|
1321
1407
|
}
|
|
1322
1408
|
};
|
|
1409
|
+
const readSplit = async () => {
|
|
1410
|
+
const r = await runPython("scripts/s4l_mode.py", ["split"], { timeoutMs: 15_000 });
|
|
1411
|
+
try {
|
|
1412
|
+
const v = Number(JSON.parse((r.stdout || "").trim()).personal_brand_share);
|
|
1413
|
+
return Number.isFinite(v) ? v : 0.5;
|
|
1414
|
+
}
|
|
1415
|
+
catch {
|
|
1416
|
+
return 0.5;
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1323
1419
|
if (action === "get") {
|
|
1324
1420
|
const flags = await readFlags();
|
|
1325
1421
|
const persona = findPersonaProject();
|
|
1326
1422
|
const mode = flags.personal_brand ? "personal_brand" : "promotion";
|
|
1327
|
-
return jsonContent({
|
|
1423
|
+
return jsonContent({
|
|
1424
|
+
flags,
|
|
1425
|
+
mode,
|
|
1426
|
+
personal_brand_share: await readSplit(),
|
|
1427
|
+
persona: persona ? persona.name : null,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
// Set the both-lanes-on split (the dashboard slider): just rewrite
|
|
1431
|
+
// mode.json via s4l_mode.py — NO persona provisioning, same weight class
|
|
1432
|
+
// as action:'toggle'.
|
|
1433
|
+
if (action === "split") {
|
|
1434
|
+
if (args.split === undefined || args.split === null || args.split === "") {
|
|
1435
|
+
return jsonContent({ personal_brand_share: await readSplit() });
|
|
1436
|
+
}
|
|
1437
|
+
const res = await runPython("scripts/s4l_mode.py", ["split", String(args.split)], {
|
|
1438
|
+
timeoutMs: 15_000,
|
|
1439
|
+
});
|
|
1440
|
+
if (res.code !== 0) {
|
|
1441
|
+
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
1442
|
+
return textContent(`Could not set the lane split: ${tail}`);
|
|
1443
|
+
}
|
|
1444
|
+
try {
|
|
1445
|
+
return jsonContent(JSON.parse((res.stdout || "").trim()));
|
|
1446
|
+
}
|
|
1447
|
+
catch {
|
|
1448
|
+
return jsonContent({ personal_brand_share: await readSplit() });
|
|
1449
|
+
}
|
|
1328
1450
|
}
|
|
1329
1451
|
// Lightweight flip of ONE lane (the dashboard/menu-bar quick toggle): just
|
|
1330
1452
|
// rewrite mode.json via s4l_mode.py — NO persona provisioning. Mirrors the
|
|
@@ -1357,7 +1479,7 @@ tool("engagement_mode", {
|
|
|
1357
1479
|
}
|
|
1358
1480
|
if (!personalBrand && !promotion) {
|
|
1359
1481
|
return textContent("At least one lane must be on. personal_brand is the default; set promotion:true if the user " +
|
|
1360
|
-
"also wants product promotion (both on -> the cycle splits 50/50).");
|
|
1482
|
+
"also wants product promotion (both on -> the cycle splits per the lane split, default 50/50).");
|
|
1361
1483
|
}
|
|
1362
1484
|
const mode = personalBrand ? "personal_brand" : "promotion";
|
|
1363
1485
|
recordOnboardingAttempt("mode_chosen", { personal_brand: personalBrand, promotion });
|
|
@@ -1367,6 +1489,11 @@ tool("engagement_mode", {
|
|
|
1367
1489
|
blockOnboardingMilestone("mode_chosen", "mode_set_failed", tail, { personal_brand: personalBrand, promotion });
|
|
1368
1490
|
return textContent(`Couldn't save the engagement lanes: ${tail}`);
|
|
1369
1491
|
}
|
|
1492
|
+
// Optional lane split rider on 'set' (best-effort: the lanes are saved
|
|
1493
|
+
// either way, and the split keeps its previous/default value on failure).
|
|
1494
|
+
if (args.split !== undefined && args.split !== null && args.split !== "") {
|
|
1495
|
+
await runPython("scripts/s4l_mode.py", ["split", String(args.split)], { timeoutMs: 15_000 });
|
|
1496
|
+
}
|
|
1370
1497
|
// NOTE (2026-07-06): the draft-only flag (mode.json {"draft_only": false} —
|
|
1371
1498
|
// promotion cycles POST autonomously instead of drafting cards) is
|
|
1372
1499
|
// DELIBERATELY NOT exposed on this tool or any user surface. It is an
|
|
@@ -1449,7 +1576,8 @@ tool("engagement_mode", {
|
|
|
1449
1576
|
const bothOn = personalBrand && promotion;
|
|
1450
1577
|
const next_step = promotion
|
|
1451
1578
|
? (bothOn
|
|
1452
|
-
? "Personal brand + product promotion are BOTH on (the cycle splits
|
|
1579
|
+
? "Personal brand + product promotion are BOTH on (the cycle splits per the lane split, " +
|
|
1580
|
+
"default 50/50 — adjustable via action:'split' or the dashboard slider), and the persona " +
|
|
1453
1581
|
"is provisioned + topic-seeded. "
|
|
1454
1582
|
: "Product promotion is on and the persona is provisioned. ") +
|
|
1455
1583
|
"NOW CONTINUE SETUP: configure the product project with project_config (research the product " +
|
|
@@ -1458,8 +1586,10 @@ tool("engagement_mode", {
|
|
|
1458
1586
|
? `Personal-brand lane is on (the default); the persona is provisioned, topic-seeded, and ${personaQueryCount} search quer${personaQueryCount === 1 ? "y" : "ies"} seeded, so there `
|
|
1459
1587
|
: "Personal-brand lane is on (the default); the persona is provisioned + topic-seeded (but NO search_queries were supplied, so it will run one topic-as-query at a time — re-call engagement_mode action:'set' with a search_queries array of ~30 X search strings expanded from the persona topics to fan out). There ") +
|
|
1460
1588
|
"is nothing more to configure (no product project is needed). NOW SCHEDULE THE AUTOPILOT: call " +
|
|
1461
|
-
"queue_setup and create each returned task with create_scheduled_task (prompt
|
|
1462
|
-
"'already exists' is fine
|
|
1589
|
+
"queue_setup and create each returned task with create_scheduled_task (prompt and " +
|
|
1590
|
+
"notifyOnCompletion verbatim — notifyOnCompletion must be false; 'already exists' is fine, but then " +
|
|
1591
|
+
"call update_scheduled_task with notifyOnCompletion:false to silence per-run notifications), " +
|
|
1592
|
+
"then call the dashboard tool to confirm the schedule is firing. " +
|
|
1463
1593
|
"That is the final setup step — do NOT stop before the autopilot is scheduled.";
|
|
1464
1594
|
return jsonContent({
|
|
1465
1595
|
ok: true,
|
|
@@ -1747,9 +1877,12 @@ tool("project_config", {
|
|
|
1747
1877
|
"a phrase bank + things they avoid, and their icp. The scan is BACKWARD-LOOKING (only what " +
|
|
1748
1878
|
"they already posted) so it is the source for VOICE, not the primary source for topics. " +
|
|
1749
1879
|
"SECOND (the DICTATION interview — this is where TOPICS + grounding corpus come from, do NOT " +
|
|
1750
|
-
"skip it and do NOT infer topics from the scan alone):
|
|
1880
|
+
"skip it and do NOT infer topics from the scan alone): invite the user to answer the " +
|
|
1751
1881
|
"following in ONE spoken dictation (the Claude input box already supports dictation, so they " +
|
|
1752
|
-
"just talk once and you split the answers into fields).
|
|
1882
|
+
"just talk once and you split the answers into fields). KEEP THE FRAMING CHILL: this is a " +
|
|
1883
|
+
"casual brain-dump, not a form. No pressure; they can answer as much or as little as they " +
|
|
1884
|
+
"like, skip anything, and come back to the rest whenever they feel like it. Preface the list " +
|
|
1885
|
+
"with one short low-key line saying exactly that, then ask verbatim, as a single numbered " +
|
|
1753
1886
|
"list:\n" +
|
|
1754
1887
|
" 1. Who are you, and what do you want to be known for? (-> description)\n" +
|
|
1755
1888
|
" 2. What subjects could you talk about for an hour, work and non-work? (-> search_topics: " +
|
|
@@ -1768,12 +1901,15 @@ tool("project_config", {
|
|
|
1768
1901
|
"Then SYNTHESIZE the fields from their dictation: search_topics comes PRIMARILY from answer 2 " +
|
|
1769
1902
|
"(fold in recurring scan themes only as reinforcement); description/content_angle/voice from " +
|
|
1770
1903
|
"the rest. Keep their RAW transcript VERBATIM as content_corpus (do NOT paraphrase; their " +
|
|
1771
|
-
"actual numbers, opinions, and phrasing are what make drafts sound like them). If
|
|
1772
|
-
"
|
|
1904
|
+
"actual numbers, opinions, and phrasing are what make drafts sound like them). If they " +
|
|
1905
|
+
"answer only some questions, take what they gave, continue without nagging, and mention once " +
|
|
1906
|
+
"that they can answer the rest any time later to make drafts sound more like them. If the " +
|
|
1907
|
+
"user declines or gives nothing usable, fall back to scan-derived topics. " +
|
|
1773
1908
|
"THIRD (engagement lanes — ASK THE USER, do not infer): the PERSONAL BRAND lane (organic, " +
|
|
1774
1909
|
"link-free engagement in their own voice) is ON by default, so ask the ONE question — do they " +
|
|
1775
1910
|
"ALSO want to PROMOTE a PRODUCT (the marketing lane, link replies)? Both lanes can run (the " +
|
|
1776
|
-
"cycle splits 50/50). Call the `engagement_mode` tool
|
|
1911
|
+
"cycle splits per the configurable lane split, default 50/50). Call the `engagement_mode` tool " +
|
|
1912
|
+
"action:'set' with personal_brand:true, " +
|
|
1777
1913
|
"promotion:true|false AND the voice/description/search_topics you synthesized PLUS the raw " +
|
|
1778
1914
|
"dictation transcript as content_corpus (this provisions the persona and seeds topics). Only " +
|
|
1779
1915
|
"NOW are topics seeded — postponed until the dictation is in. " +
|
|
@@ -1812,6 +1948,7 @@ tool("project_config", {
|
|
|
1812
1948
|
update_available: !!snap.update_available,
|
|
1813
1949
|
mode: snap.mode,
|
|
1814
1950
|
flags: snap.flags,
|
|
1951
|
+
personal_brand_share: snap.personal_brand_share,
|
|
1815
1952
|
update_hint: snap.update_available
|
|
1816
1953
|
? `A newer version (${snap.latest_version}) is available — you're on ${snap.version}. ` +
|
|
1817
1954
|
`Tell the user and offer to run the \`runtime\` tool with action:'update' ` +
|
|
@@ -1829,7 +1966,7 @@ tool("project_config", {
|
|
|
1829
1966
|
(xConnected ? "" : " X is not connected yet either — detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn.")
|
|
1830
1967
|
: projects.every((p) => p.ready)
|
|
1831
1968
|
? (xConnected
|
|
1832
|
-
? "All configured projects are ready and X is connected. SCHEDULE THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt verbatim; 'already exists' is fine); (2) the autopilot then runs on its own (launchd kicker + queue worker). Call the `dashboard` tool to confirm the schedule is firing (schedule_state 'ok') — that is the terminal step; do NOT wait for or verify a draft card. Do NOT pause to ask the user to review drafts."
|
|
1969
|
+
? "All configured projects are ready and X is connected. SCHEDULE THE AUTOPILOT: (1) call queue_setup and create each returned task with create_scheduled_task (prompt and notifyOnCompletion verbatim — notifyOnCompletion must be false; 'already exists' is fine, but then call update_scheduled_task with notifyOnCompletion:false to silence per-run notifications); (2) the autopilot then runs on its own (launchd kicker + queue worker). Call the `dashboard` tool to confirm the schedule is firing (schedule_state 'ok') — that is the terminal step; do NOT wait for or verify a draft card. Do NOT pause to ask the user to review drafts."
|
|
1833
1970
|
: "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
|
|
1834
1971
|
"x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
|
|
1835
1972
|
: "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call project_config again. Ask only if a required field is genuinely unknowable." +
|
|
@@ -1952,7 +2089,8 @@ tool("project_config", {
|
|
|
1952
2089
|
? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
|
|
1953
2090
|
`detect sources, warn about keychain prompts, and call project_config with ` +
|
|
1954
2091
|
`action:'connect_x', confirm:true immediately. Once X is connected, schedule the autopilot ` +
|
|
1955
|
-
`(queue_setup + create_scheduled_task per task
|
|
2092
|
+
`(queue_setup + create_scheduled_task per task, passing each spec's fields verbatim including ` +
|
|
2093
|
+
`notifyOnCompletion:false); the autopilot then drafts on its own. Call the ` +
|
|
1956
2094
|
`dashboard to confirm the schedule is firing (schedule_state 'ok') — that is the final step, ` +
|
|
1957
2095
|
`no need to wait for or verify a draft card.`
|
|
1958
2096
|
: `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
|
|
@@ -2348,8 +2486,9 @@ tool("queue_setup", {
|
|
|
2348
2486
|
title: "Get autopilot scheduled-task specs",
|
|
2349
2487
|
description: "Returns the scheduled task that runs the hands-free draft autopilot on this machine " +
|
|
2350
2488
|
"(s4l-worker, the universal queue worker). For EACH returned task, call the host tool " +
|
|
2351
|
-
"create_scheduled_task with its taskId, cronExpression,
|
|
2352
|
-
"prompt — it contains exact local paths
|
|
2489
|
+
"create_scheduled_task with its taskId, cronExpression, prompt, and notifyOnCompletion " +
|
|
2490
|
+
"VERBATIM (do not edit the prompt — it contains exact local paths; notifyOnCompletion MUST be " +
|
|
2491
|
+
"false or the per-minute worker spams a notification every run). The task drains the local job queue that the " +
|
|
2353
2492
|
"real pipeline feeds (all job types); the pipeline itself is kicked by launchd jobs this server " +
|
|
2354
2493
|
"installs. Use this as the final onboarding step instead of the old per-type worker tasks.",
|
|
2355
2494
|
inputSchema: {},
|
|
@@ -2394,6 +2533,11 @@ tool("queue_setup", {
|
|
|
2394
2533
|
cronExpression: "* * * * *",
|
|
2395
2534
|
folder: workerFolder,
|
|
2396
2535
|
prompt: queueWorkerBody(spec),
|
|
2536
|
+
// The host defaults notifyOnCompletion to TRUE, which fires a Notification
|
|
2537
|
+
// Center toast after EVERY run — for a per-minute worker whose normal
|
|
2538
|
+
// report is "no jobs", that's up to 60 "couldn't find anything" popups an
|
|
2539
|
+
// hour. A customer disabled the autopilot over exactly this (2026-07-06).
|
|
2540
|
+
notifyOnCompletion: false,
|
|
2397
2541
|
}));
|
|
2398
2542
|
return jsonContent({
|
|
2399
2543
|
tasks,
|
|
@@ -2410,11 +2554,15 @@ tool("queue_setup", {
|
|
|
2410
2554
|
"- One helper job (s4l-worker) runs every minute to drain the background work queue. Leave it enabled; it only drafts, it never posts.",
|
|
2411
2555
|
"- You can edit your voice, topics, or the drafts themselves at any time, and check status on the dashboard.",
|
|
2412
2556
|
],
|
|
2413
|
-
next_step: "For EACH task, call the host tool create_scheduled_task (taskId, cronExpression, prompt " +
|
|
2414
|
-
"verbatim) to register its schedule — the prompt file is already written, so creating it just " +
|
|
2415
|
-
"wires up the cron.
|
|
2416
|
-
"
|
|
2417
|
-
"
|
|
2557
|
+
next_step: "For EACH task, call the host tool create_scheduled_task (taskId, cronExpression, prompt, " +
|
|
2558
|
+
"notifyOnCompletion verbatim) to register its schedule — the prompt file is already written, so creating it just " +
|
|
2559
|
+
"wires up the cron. notifyOnCompletion:false is REQUIRED: the host default is true, and a " +
|
|
2560
|
+
"per-minute worker whose normal result is 'no jobs' would otherwise pop a notification every " +
|
|
2561
|
+
"run. If create_scheduled_task reports a task already exists, that is FINE — the " +
|
|
2562
|
+
"schedule is registered and the prompt is current; do NOT delete/recreate, do NOT investigate — " +
|
|
2563
|
+
"but DO silence its run notifications once: call the host tool update_scheduled_task with the " +
|
|
2564
|
+
"same taskId and notifyOnCompletion:false (existing installs were created before this default " +
|
|
2565
|
+
"was overridden), then move on. It fires every minute and only drafts — it never posts. The pipeline that feeds " +
|
|
2418
2566
|
"it runs from launchd jobs this server installs. Do NOT create the old " +
|
|
2419
2567
|
"'social-autoposter-autopilot' task; it is deprecated. If the older per-type tasks " +
|
|
2420
2568
|
"(saps-phase1-query / saps-phase2b-draft) exist from a previous version, LEAVE them — they are " +
|
|
@@ -2936,8 +3084,9 @@ function ensureCoworkMcpRegistered() {
|
|
|
2936
3084
|
// ---- launchd kicker: run the REAL pipeline in queue mode ---------------------
|
|
2937
3085
|
// Reinstates com.m13v.social-twitter-cycle as the customer-box kicker. It runs
|
|
2938
3086
|
// run-twitter-cycle.sh straight through (scan -> score -> draft -> link-gen); its
|
|
2939
|
-
// `claude -p` steps
|
|
2940
|
-
// the
|
|
3087
|
+
// `claude -p` steps carry queue-mapped tags, so run_claude.sh routes them through
|
|
3088
|
+
// the job queue (claude_job.py TAG_TO_TYPE) for the scheduled-task workers to
|
|
3089
|
+
// service. The per-cycle DRAFT_ONLY value is NOT
|
|
2941
3090
|
// baked here anymore: run-draft-and-publish.sh decides it from mode.json
|
|
2942
3091
|
// (draft-only flag, 2026-07-06) — while draft-only is ON (default) cycles stop
|
|
2943
3092
|
// before posting and merge into review cards; with draft-only OFF (operator
|
|
@@ -2951,7 +3100,8 @@ const QUEUE_KICKER_INTERVAL_SECS = 60;
|
|
|
2951
3100
|
function kickerEnv() {
|
|
2952
3101
|
return {
|
|
2953
3102
|
DRAFT_ONLY: "1",
|
|
2954
|
-
S4L_CLAUDE_PROVIDER:
|
|
3103
|
+
// S4L_CLAUDE_PROVIDER is gone (2026-07-06): queue routing is by script tag
|
|
3104
|
+
// (claude_job.py TAG_TO_TYPE), so the plist carries no routing switch.
|
|
2955
3105
|
S4L_STATE_DIR: s4lStateDir(),
|
|
2956
3106
|
TWITTER_PAGE_GEN_RATE: "0",
|
|
2957
3107
|
// Thread-media context for the drafter (2026-07-06): parity with the
|
|
@@ -3722,6 +3872,22 @@ function isPostingFlagFresh() {
|
|
|
3722
3872
|
return false;
|
|
3723
3873
|
}
|
|
3724
3874
|
}
|
|
3875
|
+
// True when a DIFFERENT process holds a fresh posting flag — i.e. a sibling MCP
|
|
3876
|
+
// instance is mid-drain. Our own fresh flag doesn't count: same-process
|
|
3877
|
+
// re-entrancy is covered by the in-memory `postingActive`, and a leftover flag
|
|
3878
|
+
// from our own earlier drain (a crash before the finally cleared it) must not
|
|
3879
|
+
// deadlock us against ourselves.
|
|
3880
|
+
function isPeerDrainActive() {
|
|
3881
|
+
try {
|
|
3882
|
+
const j = JSON.parse(fs.readFileSync(postingFlagPath(), "utf-8"));
|
|
3883
|
+
if (typeof j?.expires_at !== "number" || j.expires_at <= Date.now())
|
|
3884
|
+
return false;
|
|
3885
|
+
return typeof j?.pid === "number" && j.pid !== process.pid;
|
|
3886
|
+
}
|
|
3887
|
+
catch {
|
|
3888
|
+
return false;
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3725
3891
|
// activity.json: a tiny "what's running right now" signal the menu bar reads to
|
|
3726
3892
|
// show a loading spinner + label (scanning / drafting / posting / …). Written by
|
|
3727
3893
|
// long-running tools, cleared when they finish. Best-effort; absence == idle.
|