@m13v/s4l 1.7.12-rc.7 → 1.7.12-rc.9
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 +41 -18
- package/mcp/dist/setup.js +35 -28
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +24 -0
- package/mcp/package.json +1 -1
- package/package.json +18 -1
- package/scripts/angles.py +8 -0
- package/scripts/claude_job.py +13 -29
- package/scripts/config.py +51 -3
- package/scripts/harness_overlay.py +84 -39
- package/scripts/post_reddit.py +40 -43
- package/scripts/reddit_ban_check.py +28 -28
- package/scripts/s4l_box_update.sh +25 -4
- package/scripts/schedule_state.py +9 -5
- package/scripts/voice_exemplars.py +8 -3
- package/skill/run-twitter-cycle.sh +6 -0
- package/scripts/tlh_freshness_284.py +0 -134
- package/scripts/tlh_pick_248.py +0 -92
- package/scripts/tlh_pick_504.py +0 -115
- package/scripts/tlh_pick_505.py +0 -61
package/mcp/dist/index.js
CHANGED
|
@@ -3832,8 +3832,8 @@ async function autopilotLoaded() {
|
|
|
3832
3832
|
// fires every minute, claims ONE job, runs the pipeline's own prompt as its
|
|
3833
3833
|
// Claude turn, writes the result back, and stops.
|
|
3834
3834
|
// ===========================================================================
|
|
3835
|
-
const QUEUE_WORKER_PROMPT_VERSION =
|
|
3836
|
-
//
|
|
3835
|
+
const QUEUE_WORKER_PROMPT_VERSION = 10; // v10 (2026-10-29): poll window 900s -> 540s so one `next` call fits inside the Bash tool's 600s default timeout, self-reap disabled (S4L_WORKER_SELF_REAP=0 on every claude_job call), and the session ends by archiving itself instead of a SIGTERM. v9 (2026-07-17): poll window widened 240s -> 900s; version bump forces the prompt refresh that carries the new --wait-seconds onto existing installs. 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.
|
|
3836
|
+
// v11 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
|
|
3837
3837
|
// sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
|
|
3838
3838
|
// within one continuous worker session) instead of drafting inline. Validated
|
|
3839
3839
|
// via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
|
|
@@ -3842,10 +3842,19 @@ const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 2
|
|
|
3842
3842
|
// notification) or the host kills the whole parent+child chain in 1-3 min.
|
|
3843
3843
|
// Never live-fire tested against a real production job. Full design, what's
|
|
3844
3844
|
// validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
|
|
3845
|
-
// Bump this constant to
|
|
3845
|
+
// Bump this constant to 11 only once that plan is actually implemented.
|
|
3846
3846
|
const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
3847
3847
|
// How long ONE `next --wait-seconds` call polls before giving up and exiting.
|
|
3848
|
-
//
|
|
3848
|
+
// 540s (9 min, 2026-10-29, down from 900s): MUST stay below the Bash tool's
|
|
3849
|
+
// 600s default timeout. Measured on 2026-10-29 in Claude Desktop 4.29: the
|
|
3850
|
+
// 900s poll hit the 600s tool timeout on every empty cycle (81 of 106 runs
|
|
3851
|
+
// that day), the CLI then re-emitted a ~200KB prompt snapshot (a 7s
|
|
3852
|
+
// main-thread stall in the app), the poller kept running as an orphaned
|
|
3853
|
+
// background task for the remaining 300s, and its self-reap SIGTERM cost one
|
|
3854
|
+
// or two more 7s stalls plus an exit-143 re-attach. With the poll inside the
|
|
3855
|
+
// tool timeout the call returns normally, nothing is orphaned, and the worker
|
|
3856
|
+
// archives itself (step 5 of the prompt) at zero measured cost.
|
|
3857
|
+
// Earlier rationale (900s, per Matthew 2026-07-17, up from 240s): sits AT the single-
|
|
3849
3858
|
// Bash-call survival ceiling verified live on 2026-07-06 (the host's ~90s
|
|
3850
3859
|
// inactivity kill fires only on model silence, and one in-flight tool call
|
|
3851
3860
|
// survived a full 900s probe). This covers the ~8min average real job
|
|
@@ -3861,7 +3870,7 @@ const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
|
|
|
3861
3870
|
// poll window is legitimately still working, not a husk, and a too-tight
|
|
3862
3871
|
// claim_grace would SIGTERM it mid-poll before it ever gets to claim.
|
|
3863
3872
|
// (Bumped to 1020s alongside this change.)
|
|
3864
|
-
const QUEUE_WORKER_POLL_SECONDS =
|
|
3873
|
+
const QUEUE_WORKER_POLL_SECONDS = 540;
|
|
3865
3874
|
// One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
|
|
3866
3875
|
const QUEUE_WORKERS = [
|
|
3867
3876
|
{ taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
|
|
@@ -3983,8 +3992,9 @@ function queueWorkerBody(spec) {
|
|
|
3983
3992
|
`The deterministic pipeline runs on this Mac. When it needs a Claude turn it ` +
|
|
3984
3993
|
`drops a job on a local file queue. Your only job: pick up the next job, do ` +
|
|
3985
3994
|
`EXACTLY what its prompt says, hand the result back. You do this with Bash, ` +
|
|
3986
|
-
`Read, and Write, and NOTHING else
|
|
3987
|
-
`other tool, or trying to
|
|
3995
|
+
`Read, and Write, and NOTHING else (except the single archive call in step 5). ` +
|
|
3996
|
+
`This run is unattended — reaching for any other tool, or trying to ` +
|
|
3997
|
+
`"investigate", STALLS it forever.`,
|
|
3988
3998
|
``,
|
|
3989
3999
|
`PACING — CRITICAL: this unattended session is terminated ~90 seconds after ` +
|
|
3990
4000
|
`your LAST tool call (a host inactivity timeout). That clock only runs BETWEEN ` +
|
|
@@ -4000,11 +4010,16 @@ function queueWorkerBody(spec) {
|
|
|
4000
4010
|
`1. Look for the next job. Run this EXACT Bash command and let it run to ` +
|
|
4001
4011
|
`completion — it polls internally for up to ${Math.round(QUEUE_WORKER_POLL_SECONDS / 60)} ` +
|
|
4002
4012
|
`minutes before giving up, so it may take a while to return. That is normal: ` +
|
|
4003
|
-
`do NOT interrupt it and do NOT make any other tool call while it is running
|
|
4004
|
-
|
|
4013
|
+
`do NOT interrupt it and do NOT make any other tool call while it is running. ` +
|
|
4014
|
+
`Pass the Bash tool's timeout parameter as 600000 (its 10-minute maximum) so ` +
|
|
4015
|
+
`the call is never moved to the background. If the result nevertheless says ` +
|
|
4016
|
+
`the command was moved to the background (this happens when the Mac sleeps ` +
|
|
4017
|
+
`mid-poll; the timeout is wall-clock), do NOT wait for it: call TaskStop with ` +
|
|
4018
|
+
`the ID it gives you, report "no jobs" in one line, and go to step 5.`,
|
|
4019
|
+
` S4L_WORKER_SELF_REAP=0 ${py} ${job} next --type any --prompt-file --wait-seconds ${QUEUE_WORKER_POLL_SECONDS} --state-dir ${sd}`,
|
|
4005
4020
|
` It prints one line of JSON once it returns. If it prints "{}" (empty), no ` +
|
|
4006
4021
|
`job showed up during the whole poll window — report "no jobs" in one line ` +
|
|
4007
|
-
`and
|
|
4022
|
+
`and go to step 5.`,
|
|
4008
4023
|
`2. Otherwise it prints {"job_id":"...","prompt_file":"...","schema_file":...}. ` +
|
|
4009
4024
|
`Use the Read tool to read prompt_file; it is the complete, self-contained ` +
|
|
4010
4025
|
`instruction the pipeline wrote for you. If the Read result says it is partial ` +
|
|
@@ -4014,20 +4029,28 @@ function queueWorkerBody(spec) {
|
|
|
4014
4029
|
`ONLY that JSON object — no prose, no markdown, no code fences.`,
|
|
4015
4030
|
`3. Submit it. Write your JSON object to ${outDir}/out-<job_id>.json using the ` +
|
|
4016
4031
|
`Write tool (substitute the real job_id), then run this EXACT Bash command:`,
|
|
4017
|
-
` ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
|
|
4032
|
+
` S4L_WORKER_SELF_REAP=0 ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
|
|
4018
4033
|
` If it reports the result was rejected (bad JSON / missing keys), fix your JSON ` +
|
|
4019
4034
|
`and submit again — at most twice. If it still fails, run ` +
|
|
4020
|
-
|
|
4021
|
-
`reason, then Ctrl-D) and
|
|
4022
|
-
`4. Report in ONE short line what you did
|
|
4023
|
-
`do NOT loop, do NOT read other files
|
|
4035
|
+
`\`S4L_WORKER_SELF_REAP=0 ${py} ${job} result --job <job_id> --error --state-dir ${sd}\` (type a one-line ` +
|
|
4036
|
+
`reason, then Ctrl-D) and go to step 5.`,
|
|
4037
|
+
`4. Report in ONE short line what you did. Do NOT claim another job, ` +
|
|
4038
|
+
`do NOT loop, do NOT read other files.`,
|
|
4039
|
+
`5. End this session yourself. Call ToolSearch with the query ` +
|
|
4040
|
+
`"select:mcp__ccd_session_mgmt__archive_session", then call ` +
|
|
4041
|
+
`mcp__ccd_session_mgmt__archive_session with session_id "self" and reason ` +
|
|
4042
|
+
`"s4l worker done". That archive call must be your very last tool call; it ` +
|
|
4043
|
+
`ends the session cleanly (no SIGTERM, no idle wait). If the tool is not ` +
|
|
4044
|
+
`available, just STOP.`,
|
|
4024
4045
|
``,
|
|
4025
4046
|
`HARD RULES: use ONLY the Bash tool (to run claude_job.py AND any persist ` +
|
|
4026
4047
|
`commands the job's prompt explicitly gives you), the Read tool (the ` +
|
|
4027
4048
|
`prompt/schema sidecar + the SKILL/config files the prompt names), and the ` +
|
|
4028
|
-
`Write tool (the result file)
|
|
4029
|
-
`
|
|
4030
|
-
`
|
|
4049
|
+
`Write tool (the result file), plus TaskStop ONLY for a backgrounded poll, and ` +
|
|
4050
|
+
`ToolSearch and mcp__ccd_session_mgmt__archive_session ONLY as the final step 5. NEVER post, ` +
|
|
4051
|
+
`reply, open a browser, or run any command the prompt does not explicitly ` +
|
|
4052
|
+
`give you. An empty queue is the NORMAL, expected case most minutes — it is ` +
|
|
4053
|
+
`success, not a problem to debug.`,
|
|
4031
4054
|
].join("\n");
|
|
4032
4055
|
}
|
|
4033
4056
|
// Full canonical SKILL.md (frontmatter + body + version marker) the MCP writes
|
package/mcp/dist/setup.js
CHANGED
|
@@ -45,6 +45,37 @@ export function configPath() {
|
|
|
45
45
|
return p; // not created yet: return the intended path unresolved
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
// Atomic config.json write (2026-10-30). Never write config.json in place:
|
|
49
|
+
// writeFileSync truncates the file first, so a reader in that window (or an
|
|
50
|
+
// interrupted write) sees a torn file and every pipeline lane dies on it.
|
|
51
|
+
// Write a same-directory temp file, fsync, then rename over the target so
|
|
52
|
+
// readers only ever see the old or the new complete file. Keeps the
|
|
53
|
+
// timestamped .bak- copy the call sites used to make.
|
|
54
|
+
export function writeConfigAtomic(cfg) {
|
|
55
|
+
const cfgPath = configPath();
|
|
56
|
+
if (fs.existsSync(cfgPath)) {
|
|
57
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
58
|
+
fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
|
|
59
|
+
}
|
|
60
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
61
|
+
const tmp = `${cfgPath}.tmp-${process.pid}`;
|
|
62
|
+
try {
|
|
63
|
+
const fd = fs.openSync(tmp, "w");
|
|
64
|
+
try {
|
|
65
|
+
fs.writeSync(fd, JSON.stringify(cfg, null, 2) + "\n");
|
|
66
|
+
fs.fsyncSync(fd);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
fs.closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
fs.renameSync(tmp, cfgPath);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (fs.existsSync(tmp))
|
|
75
|
+
fs.unlinkSync(tmp);
|
|
76
|
+
}
|
|
77
|
+
return cfgPath;
|
|
78
|
+
}
|
|
48
79
|
// One-time (idempotent) migration to the state-dir home, run at server boot.
|
|
49
80
|
// Cases:
|
|
50
81
|
// - no state config, legacy is a REAL file -> move it up, leave a symlink
|
|
@@ -360,13 +391,7 @@ export function applySetup(input) {
|
|
|
360
391
|
cfg.projects.push(entry);
|
|
361
392
|
created = true;
|
|
362
393
|
}
|
|
363
|
-
|
|
364
|
-
if (fs.existsSync(cfgPath)) {
|
|
365
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
366
|
-
fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
|
|
367
|
-
}
|
|
368
|
-
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
369
|
-
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
394
|
+
writeConfigAtomic(cfg);
|
|
370
395
|
void sendStateSnapshot("config_write");
|
|
371
396
|
if (!persona)
|
|
372
397
|
recordManagedProject(input.name);
|
|
@@ -418,13 +443,7 @@ export function recordRedditAccount(username) {
|
|
|
418
443
|
if (!reddit.login_method)
|
|
419
444
|
reddit.login_method = "browser";
|
|
420
445
|
try {
|
|
421
|
-
|
|
422
|
-
if (fs.existsSync(cfgPath)) {
|
|
423
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
424
|
-
fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
|
|
425
|
-
}
|
|
426
|
-
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
427
|
-
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
446
|
+
writeConfigAtomic(cfg);
|
|
428
447
|
void sendStateSnapshot("config_write");
|
|
429
448
|
return { written: true, detail: `accounts.reddit.username = ${clean}` };
|
|
430
449
|
}
|
|
@@ -541,13 +560,7 @@ export function ensurePersonaProject(grounding) {
|
|
|
541
560
|
name = PERSONA_DEFAULT_NAME;
|
|
542
561
|
created = true;
|
|
543
562
|
}
|
|
544
|
-
const cfgPath =
|
|
545
|
-
if (fs.existsSync(cfgPath)) {
|
|
546
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
547
|
-
fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
|
|
548
|
-
}
|
|
549
|
-
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
550
|
-
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
563
|
+
const cfgPath = writeConfigAtomic(cfg);
|
|
551
564
|
// Raw dictation transcript -> persona_corpus.txt sidecar (NOT config.json).
|
|
552
565
|
// Mirrors scripts/build_persona.py cmd_apply: config.json is inlined into many
|
|
553
566
|
// prompts (ALL_PROJECTS_JSON), so a multi-KB corpus there would bloat every
|
|
@@ -598,13 +611,7 @@ export function ensureShortLinksDefault() {
|
|
|
598
611
|
changed = true;
|
|
599
612
|
}
|
|
600
613
|
if (changed) {
|
|
601
|
-
|
|
602
|
-
if (fs.existsSync(cfgPath)) {
|
|
603
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
604
|
-
fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
|
|
605
|
-
}
|
|
606
|
-
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
607
|
-
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
614
|
+
writeConfigAtomic(cfg);
|
|
608
615
|
void sendStateSnapshot("config_write");
|
|
609
616
|
}
|
|
610
617
|
}
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.12-rc.
|
|
5
|
+
"version": "1.7.12-rc.9",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -1690,6 +1690,30 @@ class S4LMenuBar(rumps.App):
|
|
|
1690
1690
|
verification on such machines.
|
|
1691
1691
|
"""
|
|
1692
1692
|
app_support = os.path.expanduser("~/Library/Application Support")
|
|
1693
|
+
# LIVE profile first (2026-09-28): on a multi-profile machine (account
|
|
1694
|
+
# rotator: Claude, Claude-mediar, Claude-mddy, ...) every profile keeps
|
|
1695
|
+
# its OWN copy of the extension, and "newest mtime" is simply the
|
|
1696
|
+
# profile that was updated most recently, not the one Claude is
|
|
1697
|
+
# running right now. The operator Mac sat on 1.7.7-rc.4 in the live
|
|
1698
|
+
# Claude-mediar profile for two months while every update landed in
|
|
1699
|
+
# Claude-mddy, and the updater kept reporting "already on latest".
|
|
1700
|
+
# Prefer the extension dir under a RUNNING Claude's --user-data-dir
|
|
1701
|
+
# (default profile == plain "Claude"); fall back to the newest-mtime
|
|
1702
|
+
# scan only when no Claude instance is running. Mirrors
|
|
1703
|
+
# scripts/s4l_box_update.sh; keep the two in sync.
|
|
1704
|
+
for udd in S4LMenuBar._claude_user_data_dirs():
|
|
1705
|
+
root = os.path.join(udd or os.path.join(app_support, "Claude"),
|
|
1706
|
+
"Claude Extensions")
|
|
1707
|
+
try:
|
|
1708
|
+
names = sorted(os.listdir(root))
|
|
1709
|
+
except OSError:
|
|
1710
|
+
continue
|
|
1711
|
+
for name in names:
|
|
1712
|
+
if not name.endswith("social-autoposter"):
|
|
1713
|
+
continue
|
|
1714
|
+
d = os.path.join(root, name)
|
|
1715
|
+
if os.path.exists(os.path.join(d, "manifest.json")):
|
|
1716
|
+
return d
|
|
1693
1717
|
best, best_mtime = None, -1.0
|
|
1694
1718
|
for root in glob.glob(os.path.join(app_support, "Claude*", "Claude Extensions")):
|
|
1695
1719
|
try:
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.12-rc.
|
|
3
|
+
"version": "1.7.12-rc.9",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l",
|
|
3
|
-
"version": "1.7.12-rc.
|
|
3
|
+
"version": "1.7.12-rc.9",
|
|
4
4
|
"description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"social-autoposter": "bin/cli.js",
|
|
@@ -41,6 +41,23 @@
|
|
|
41
41
|
"!scripts/_gsc_roi_query.py",
|
|
42
42
|
"!scripts/batch_send_dms.py",
|
|
43
43
|
"!scripts/finalize_post_*.py",
|
|
44
|
+
"!scripts/insert_[0-9]*.py",
|
|
45
|
+
"!scripts/insert[0-9]*.py",
|
|
46
|
+
"!scripts/ins[0-9]*.py",
|
|
47
|
+
"!scripts/tlh_pick_*.py",
|
|
48
|
+
"!scripts/tlh_freshness_*.py",
|
|
49
|
+
"!scripts/tlh_fresh.py",
|
|
50
|
+
"!scripts/tlh_stale.py",
|
|
51
|
+
"!scripts/fresh[0-9]*.py",
|
|
52
|
+
"!scripts/hist[0-9]*.py",
|
|
53
|
+
"!scripts/q[0-9]*.py",
|
|
54
|
+
"!scripts/show[0-9]*.py",
|
|
55
|
+
"!scripts/stale[0-9]*.py",
|
|
56
|
+
"!scripts/stale_[0-9]*.py",
|
|
57
|
+
"!scripts/stale_classes_*.py",
|
|
58
|
+
"!scripts/stale_clips_*.py",
|
|
59
|
+
"!scripts/r[0-9]*.py",
|
|
60
|
+
"!scripts/w[0-9]*.py",
|
|
44
61
|
"!scripts/insert_li_notifs.py",
|
|
45
62
|
"!scripts/li_notif_scan_process.py",
|
|
46
63
|
"!scripts/phase_d_edit.py",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import os,psycopg2
|
|
2
|
+
from dotenv import load_dotenv
|
|
3
|
+
load_dotenv(os.path.expanduser('~/social-autoposter/.env'))
|
|
4
|
+
c=psycopg2.connect(os.environ['DATABASE_URL']).cursor()
|
|
5
|
+
c.execute("select distinct metadata->>'theme_angle' from media_posts")
|
|
6
|
+
print(' '.join(sorted(r[0] or '' for r in c.fetchall())))
|
|
7
|
+
c.execute("select distinct metadata->>'engagement_style' from media_posts")
|
|
8
|
+
print([r[0] for r in c.fetchall()])
|
package/scripts/claude_job.py
CHANGED
|
@@ -1303,38 +1303,22 @@ def cmd_provider(ns) -> int:
|
|
|
1303
1303
|
# next (consumer side, run by a scheduled task) #
|
|
1304
1304
|
# --------------------------------------------------------------------------- #
|
|
1305
1305
|
def _agent_session_pid():
|
|
1306
|
-
"""Best-effort: the Claude agent-mode SESSION
|
|
1307
|
-
exact process the stale-session reaper (reap_stale_claude_sessions.py)
|
|
1308
|
-
target.
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1306
|
+
"""Best-effort: pid of the Claude agent-mode SESSION running THIS worker —
|
|
1307
|
+
the exact process the stale-session reaper (reap_stale_claude_sessions.py)
|
|
1308
|
+
would target. The claim is stamped with it
|
|
1309
|
+
so the reaper (a) SPARES that session for the whole drafting turn (the
|
|
1310
|
+
2026-06-29 draft-kill regression) and (b) re-queues the job once that
|
|
1311
|
+
session is dead (release_stale_claims, ~2 min). Shares _find_own_session's
|
|
1312
|
+
signature: the old private copy matched 'local-agent-mode-sessions', which
|
|
1313
|
+
current Desktop builds no longer put on the cmdline, so every claim went
|
|
1314
|
+
unstamped and a claim whose session died stranded its producer for the full
|
|
1315
|
+
~30 min timeout (2026-10-30: Desktop relaunched a minute after a claim).
|
|
1316
|
+
None if not identifiable."""
|
|
1315
1317
|
try:
|
|
1316
|
-
|
|
1317
|
-
["/bin/ps", "-axo", "pid=,ppid=,command="],
|
|
1318
|
-
capture_output=True, text=True, timeout=10,
|
|
1319
|
-
).stdout
|
|
1320
|
-
info = {}
|
|
1321
|
-
for line in out.splitlines():
|
|
1322
|
-
m = re.match(r"\s*(\d+)\s+(\d+)\s+(.*)$", line)
|
|
1323
|
-
if m:
|
|
1324
|
-
info[int(m.group(1))] = (int(m.group(2)), m.group(3))
|
|
1325
|
-
pid = os.getpid()
|
|
1326
|
-
for _ in range(16): # bounded climb up the tree
|
|
1327
|
-
ent = info.get(pid)
|
|
1328
|
-
if not ent or ent[0] <= 1:
|
|
1329
|
-
break
|
|
1330
|
-
ppid = ent[0]
|
|
1331
|
-
pcmd = info.get(ppid, (0, ""))[1]
|
|
1332
|
-
if ("claude-code/" in pcmd) and ("local-agent-mode-sessions" in pcmd):
|
|
1333
|
-
return ppid
|
|
1334
|
-
pid = ppid
|
|
1318
|
+
found = _find_own_session(_ps_pid_map())
|
|
1335
1319
|
except Exception:
|
|
1336
1320
|
return None
|
|
1337
|
-
return None
|
|
1321
|
+
return found[0] if found else None
|
|
1338
1322
|
|
|
1339
1323
|
|
|
1340
1324
|
def _attempt_claim(ns, qtype: str) -> bool:
|
package/scripts/config.py
CHANGED
|
@@ -27,15 +27,22 @@ Resolution order for the repo root (single source of truth):
|
|
|
27
27
|
$S4L_CONFIG_PATH overrides the config file location outright (some installs
|
|
28
28
|
pin the operator config, e.g. ~/s4l/config.json, independent of the code dir).
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
Config writes: user-facing edits go through the MCP server's project_config
|
|
31
|
+
tool (it owns validation and the state snapshot). The few pipeline scripts
|
|
32
|
+
that must persist runtime state into config.json (subreddit bans) MUST use
|
|
33
|
+
update_config() below and nothing else: never `open(config, "w")` +
|
|
34
|
+
json.dump. That in-place pattern truncates the file to 0 bytes and streams it
|
|
35
|
+
back, so any interruption leaves a torn prefix that breaks every reader
|
|
36
|
+
(2026-09-06, 09-30, 10-22, 10-29 outages; the 10-29 one stopped all X
|
|
37
|
+
drafting for ~23h).
|
|
33
38
|
"""
|
|
34
39
|
|
|
35
40
|
from __future__ import annotations
|
|
36
41
|
|
|
42
|
+
import fcntl
|
|
37
43
|
import json
|
|
38
44
|
import os
|
|
45
|
+
import sys
|
|
39
46
|
|
|
40
47
|
_CACHE: dict | None = None
|
|
41
48
|
_CACHE_PATH: str | None = None
|
|
@@ -98,6 +105,47 @@ def load_config(fresh: bool = False) -> dict:
|
|
|
98
105
|
return _CACHE
|
|
99
106
|
|
|
100
107
|
|
|
108
|
+
def update_config(mutate, path: str | None = None):
|
|
109
|
+
"""Locked, atomic read-modify-write of config.json.
|
|
110
|
+
|
|
111
|
+
`mutate(cfg)` edits the dict in place and returns a truthy value when it
|
|
112
|
+
changed something (nothing is written otherwise); its return value is
|
|
113
|
+
passed back to the caller. The whole read->mutate->write span holds the
|
|
114
|
+
same fcntl lock on <config>.lock that learned_preferences.py takes, so
|
|
115
|
+
concurrent writers cannot erase each other's edits. The new content goes
|
|
116
|
+
to a temp file in the same directory and is swapped in with os.replace,
|
|
117
|
+
so readers only ever see the complete old file or the complete new one;
|
|
118
|
+
a writer killed mid-write leaves a stray temp file, never a torn config.
|
|
119
|
+
|
|
120
|
+
Raises if config.json is missing or unparseable: callers must not
|
|
121
|
+
"repair" a broken config by writing a partial dict over it.
|
|
122
|
+
"""
|
|
123
|
+
global _CACHE, _CACHE_PATH, _CACHE_MTIME
|
|
124
|
+
path = path or config_path()
|
|
125
|
+
with open(path + ".lock", "w") as lock_f:
|
|
126
|
+
fcntl.flock(lock_f, fcntl.LOCK_EX)
|
|
127
|
+
with open(path) as f:
|
|
128
|
+
cfg = json.load(f)
|
|
129
|
+
result = mutate(cfg)
|
|
130
|
+
if not result:
|
|
131
|
+
return result
|
|
132
|
+
tmp = f"{path}.tmp-{os.getpid()}"
|
|
133
|
+
try:
|
|
134
|
+
with open(tmp, "w") as f:
|
|
135
|
+
f.write(json.dumps(cfg, indent=2) + "\n")
|
|
136
|
+
f.flush()
|
|
137
|
+
os.fsync(f.fileno())
|
|
138
|
+
os.replace(tmp, path)
|
|
139
|
+
finally:
|
|
140
|
+
if os.path.exists(tmp):
|
|
141
|
+
os.unlink(tmp)
|
|
142
|
+
# One attribution line per write, so the next incident names its writer.
|
|
143
|
+
caller = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else "?"
|
|
144
|
+
print(f"[config] saved {path} by {caller} pid={os.getpid()}", file=sys.stderr)
|
|
145
|
+
_CACHE, _CACHE_PATH, _CACHE_MTIME = None, None, None
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
101
149
|
def get(path: str, default=None):
|
|
102
150
|
"""Dotted-path lookup into config.json: get("accounts.twitter.handle").
|
|
103
151
|
A single-segment path reads a top-level key. Lists are not traversed;
|