@m13v/s4l 1.7.6-rc.30 → 1.7.6-rc.32

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 CHANGED
@@ -3367,35 +3367,36 @@ tool("restart_menubar", {
3367
3367
  menubar_running: running,
3368
3368
  });
3369
3369
  });
3370
- // ---- scan_now: fire one scanner cycle immediately --------------------------
3371
- // The scanner (the twitter scan+draft cycle) is what drives the whole pipeline;
3372
- // it runs on the launchd kicker's fixed StartInterval. This nudges launchd to
3373
- // fire it RIGHT NOW via `launchctl kickstart` — the EXACT same mechanism the
3374
- // first-run onboarding kick uses (ensureQueueKickerInstalled): the job runs
3375
- // THROUGH launchd with its full baked env + the run-*-singleton.sh lock, so it
3376
- // is NOT a bare `bash run-twitter-cycle.sh` (which would leave an empty-plan
3377
- // artifact). launchd keeps a single instance, so if a cycle is already running
3378
- // this is a harmless no-op; while paused the kicker is unloaded, so we report
3379
- // that rather than kick nothing.
3370
+ // ---- run_drafting: run the drafting pipeline immediately -------------------
3371
+ // The drafting pipeline (the twitter cycle) is what the user triggers: it SCANS
3372
+ // for threads first, then hands off to the drafter, so from the user's view this
3373
+ // is "run drafting now". It runs on the launchd kicker's fixed StartInterval;
3374
+ // this nudges launchd to fire it RIGHT NOW via `launchctl kickstart` — the EXACT
3375
+ // same mechanism the first-run onboarding kick uses (ensureQueueKickerInstalled):
3376
+ // the job runs THROUGH launchd with its full baked env + the run-*-singleton.sh
3377
+ // lock, so it is NOT a bare `bash run-twitter-cycle.sh` (which would leave an
3378
+ // empty-plan artifact). launchd keeps a single instance, so if a run is already
3379
+ // underway this is a harmless no-op; while paused the kicker is unloaded, so we
3380
+ // report that rather than kick nothing.
3380
3381
  //
3381
3382
  // This is deliberately NOT the removed run_draft_cycle / draft_cycle tool: it
3382
3383
  // spawns no warm in-chat Claude draft session, it only asks launchd to run the
3383
3384
  // already-scheduled cycle one interval early. Do NOT re-introduce a draft_cycle.
3384
- tool("scan_now", {
3385
- title: "Run the scanner now",
3386
- description: "Fire ONE scanner cycle immediately instead of waiting for the next scheduled run. The scanner " +
3387
- "(the twitter scan+draft cycle) is what discovers threads and kicks off drafting, so this is the " +
3388
- "'run now' control behind the menu bar + dashboard button. It nudges the existing launchd job to " +
3389
- "run one interval early; it does NOT change the schedule. If a cycle is already running it's a " +
3390
- "no-op, and if S4L is paused it reports that (nothing runs while paused). Use when the user asks " +
3391
- "to scan now, run the scanner, run now, or check for new threads immediately.",
3385
+ tool("run_drafting", {
3386
+ title: "Run the drafting pipeline now",
3387
+ description: "Run the drafting pipeline ONE time immediately instead of waiting for the next scheduled run. " +
3388
+ "The pipeline scans for threads and then drafts replies, so this is the 'run now' control behind " +
3389
+ "the menu bar + dashboard button. It nudges the existing launchd job to run one interval early; " +
3390
+ "it does NOT change the schedule. If a run is already underway it's a no-op, and if S4L is paused " +
3391
+ "it reports that (nothing runs while paused). Use when the user asks to run drafting, draft now, " +
3392
+ "run the pipeline, run now, or check for new threads immediately.",
3392
3393
  inputSchema: {},
3393
3394
  }, async () => {
3394
3395
  if (isPaused()) {
3395
3396
  return jsonContent({
3396
3397
  ok: false,
3397
3398
  paused: true,
3398
- detail: "S4L is paused — resume it first, then the scanner can run.",
3399
+ detail: "S4L is paused — resume it first, then drafting can run.",
3399
3400
  });
3400
3401
  }
3401
3402
  const uid = process.getuid ? process.getuid() : 0;
@@ -3404,21 +3405,21 @@ tool("scan_now", {
3404
3405
  return jsonContent({
3405
3406
  ok,
3406
3407
  detail: ok
3407
- ? "Scanner cycle kicked off — new drafts land in a few minutes."
3408
- : `Could not start the scanner (launchctl rc=${kick.code}).`,
3408
+ ? "Drafting started — new drafts land in a few minutes."
3409
+ : `Could not start drafting (launchctl rc=${kick.code}).`,
3409
3410
  });
3410
3411
  });
3411
- // ---- scan_status: live pipeline status + next-scanner-run countdown --------
3412
- // A cheap, pure READ the dashboard widget polls (~5s) to keep its scanner status
3412
+ // ---- drafting_status: live pipeline status + next-run countdown ------------
3413
+ // A cheap, pure READ the dashboard widget polls (~5s) to keep its drafting status
3413
3414
  // pill + countdown live between heavier snapshot refreshes. Returns the SAME
3414
3415
  // fields the menu bar renders (scripts/live_status.py — activity_state,
3415
3416
  // next_run_secs, …), plus `paused`, so the two surfaces can't drift. No side
3416
3417
  // effects: safe to poll and to replay over the loopback /tool/ endpoint.
3417
- tool("scan_status", {
3418
- title: "Live scanner status + next-run countdown",
3419
- description: "Read-only: what the pipeline is doing right now (scanning/drafting/posting/idle) and how many " +
3420
- "seconds until the next scanner run. Backs the dashboard's live status pill + countdown. Pure " +
3421
- "read; safe to poll frequently.",
3418
+ tool("drafting_status", {
3419
+ title: "Live drafting-pipeline status + next-run countdown",
3420
+ description: "Read-only: what the drafting pipeline is doing right now (scanning/drafting/posting/idle) and " +
3421
+ "how many seconds until the next scheduled run. Backs the dashboard's live status pill + " +
3422
+ "countdown. Pure read; safe to poll frequently.",
3422
3423
  inputSchema: {},
3423
3424
  }, async () => {
3424
3425
  let live = {};
@@ -70,7 +70,7 @@ Boolean requesting whether a visible border and background is provided by the ho
70
70
  - omitted: host decides border`)});m({method:u("ui/request-display-mode"),params:m({mode:st.describe("The display mode being requested.")})});var Ah=m({mode:st.describe("The display mode that was actually set. May differ from requested if not supported.")}).passthrough(),Lh=D([u("model"),u("app")]).describe("Tool visibility scope - who can access the tool.");m({resourceUri:d().optional(),visibility:x(Lh).optional().describe(`Who can access this tool. Default: ["model", "app"]
71
71
  - "model": Tool visible to and callable by the agent
72
72
  - "app": Tool callable by the app from this server only`),csp:Te().optional(),permissions:Te().optional()});m({mimeTypes:x(d()).optional().describe('Array of supported MIME types for UI resources.\nMust include `"text/html;profile=mcp-app"` for MCP Apps support.')});m({method:u("ui/download-file"),params:m({contents:x(D([Dl,Cl])).describe("Resource contents to download — embedded (inline data) or linked (host fetches). Uses standard MCP resource types.")})});m({method:u("ui/message"),params:m({role:u("user").describe('Message role, currently only "user" is supported.'),content:x(jt).describe("Message content blocks (text, image, etc.).")})});m({method:u("ui/notifications/sandbox-resource-ready"),params:m({html:d().describe("HTML content to load into the inner iframe."),sandbox:d().optional().describe("Optional override for the inner iframe's sandbox attribute."),csp:Po.optional().describe("CSP configuration from resource metadata."),permissions:Uo.optional().describe("Sandbox permissions from resource metadata.")})});var Mh=m({method:u("ui/notifications/tool-result"),params:er.describe("Standard MCP tool execution result.")}),ql=m({toolInfo:m({id:kt.optional().describe("JSON-RPC id of the tools/call request."),tool:To.describe("Tool definition including name, inputSchema, etc.")}).optional().describe("Metadata of the tool call that instantiated this App."),theme:Ih.optional().describe("Current color theme preference."),styles:Dh.optional().describe("Style configuration for theming the app."),displayMode:st.optional().describe("How the UI is currently displayed."),availableDisplayModes:x(st).optional().describe("Display modes the host supports."),containerDimensions:D([m({height:T().describe("Fixed container height in pixels.")}),m({maxHeight:D([T(),it()]).optional().describe("Maximum container height in pixels.")})]).and(D([m({width:T().describe("Fixed container width in pixels.")}),m({maxWidth:D([T(),it()]).optional().describe("Maximum container width in pixels.")})])).optional().describe(`Container dimensions. Represents the dimensions of the iframe or other
73
- container holding the app. Specify either width or maxWidth, and either height or maxHeight.`),locale:d().optional().describe("User's language and region preference in BCP 47 format."),timeZone:d().optional().describe("User's timezone in IANA format."),userAgent:d().optional().describe("Host application identifier."),platform:D([u("web"),u("desktop"),u("mobile")]).optional().describe("Platform type for responsive design decisions."),deviceCapabilities:m({touch:F().optional().describe("Whether the device supports touch input."),hover:F().optional().describe("Whether the device supports hover interactions.")}).optional().describe("Device input capabilities."),safeAreaInsets:m({top:T().describe("Top safe area inset in pixels."),right:T().describe("Right safe area inset in pixels."),bottom:T().describe("Bottom safe area inset in pixels."),left:T().describe("Left safe area inset in pixels.")}).optional().describe("Mobile safe area boundaries in pixels.")}).passthrough(),qh=m({method:u("ui/notifications/host-context-changed"),params:ql.describe("Partial context update containing only changed fields.")});m({method:u("ui/update-model-context"),params:m({content:x(jt).optional().describe("Context content blocks (text, image, etc.)."),structuredContent:L(d(),J().describe("Structured content for machine-readable context data.")).optional().describe("Structured content for machine-readable context data.")})});m({method:u("ui/initialize"),params:m({appInfo:Yn.describe("App identification (name and version)."),appCapabilities:Zh.describe("Features and capabilities this app provides."),protocolVersion:d().describe("Protocol version this app supports.")})});var Hh=m({protocolVersion:d().describe('Negotiated protocol version string (e.g., "2025-11-21").'),hostInfo:Yn.describe("Host application identification and version."),hostCapabilities:Rh.describe("Features and capabilities provided by the host."),hostContext:ql.describe("Rich context about the host environment.")}).passthrough(),Fh={target:"draft-2020-12"};async function aa(e,n){let r=e["~standard"];if(r.jsonSchema)return r.jsonSchema[n](Fh);if(r.vendor==="zod"){let{z:o}=await ad(()=>Promise.resolve().then(()=>Tp),void 0,import.meta.url);return o.toJSONSchema(e,{io:n})}throw Error(`Schema (vendor: ${r.vendor}) does not implement Standard JSON Schema (~standard.jsonSchema). Use a library that does (zod v4, ArkType, Valibot) or wrap your schema accordingly.`)}async function sa(e,n,r=""){let o=await e["~standard"].validate(n);if(o.issues){let t=o.issues.map(i=>{var s;let a=(s=i.path)==null?void 0:s.map(c=>typeof c=="object"?c.key:c).join(".");return a?`${a}: ${i.message}`:i.message}).join("; ");throw Error(r+t)}return o.value}function Jh(e){let n=document.documentElement;n.setAttribute("data-theme",e),n.style.colorScheme=e}function Bh(e,n=document.documentElement){for(let[r,o]of Object.entries(e))o!==void 0&&n.style.setProperty(r,o)}function Vh(e){if(document.getElementById("__mcp-host-fonts"))return;let n=document.createElement("style");n.id="__mcp-host-fonts",n.textContent=e,document.head.appendChild(n)}const Kt=class Kt extends $h{constructor(r,o={},t={autoResize:!0}){super(t);R(this,"_appInfo");R(this,"_capabilities");R(this,"options");R(this,"_hostCapabilities");R(this,"_hostInfo");R(this,"_hostContext");R(this,"_registeredTools",{});R(this,"_initializedSent",!1);R(this,"eventSchemas",{toolinput:Th,toolinputpartial:Ph,toolresult:Mh,toolcancelled:Uh,hostcontextchanged:qh});R(this,"_everHadListener",new Set);R(this,"_toolHandlersInitialized",!1);R(this,"_onteardown");R(this,"_oncalltool");R(this,"_onlisttools");R(this,"sendOpenLink",this.openLink);this._appInfo=r,this._capabilities=o,this.options=t,t.allowUnsafeEval||ee({jitless:!0}),this.setRequestHandler(Xn,i=>(console.log("Received ping:",i.params),{})),this.setEventHandler("hostcontextchanged",void 0)}_assertInitialized(r){var t;if(this._initializedSent)return;let o=`[ext-apps] App.${r}() called before connect() completed the ui/initialize handshake. Await app.connect() before calling this method, or move data loading to an ontoolresult handler.`;if((t=this.options)!=null&&t.strict)throw Error(o);console.warn(`${o}. This will throw in a future release.`)}_assertHandlerTiming(r){var t;if(!Kt.ONE_SHOT_EVENTS.has(r)||this._everHadListener.has(r)||(this._everHadListener.add(r),!this._initializedSent))return;let o=`[ext-apps] "${String(r)}" handler registered after connect() completed the ui/initialize handshake. The host may have already sent this notification. Register handlers before calling app.connect().`;if((t=this.options)!=null&&t.strict)throw Error(o);console.warn(o)}setEventHandler(r,o){o&&this._assertHandlerTiming(r),super.setEventHandler(r,o)}addEventListener(r,o){this._assertHandlerTiming(r),super.addEventListener(r,o)}onEventDispatch(r,o){r==="hostcontextchanged"&&(this._hostContext={...this._hostContext,...o})}registerCapabilities(r){if(this.transport)throw Error("Cannot register capabilities after transport is established");this._capabilities=yh(this._capabilities,r)}registerTool(r,o,t){if(this._registeredTools[r])throw Error(`Tool ${r} is already registered`);let i=this,a=()=>{var p;i._initializedSent&&((p=i._capabilities.tools)!=null&&p.listChanged)&&i.sendToolListChanged()},s=o.inputSchema!==void 0,c={title:o.title,description:o.description,inputSchema:o.inputSchema,outputSchema:o.outputSchema,annotations:o.annotations,_meta:o._meta,enabled:!0,enable(){this.enabled=!0,a()},disable(){this.enabled=!1,a()},update(p){Object.assign(this,p),a()},remove(){i._registeredTools[r]===c&&(delete i._registeredTools[r],a())},handler:async(p,h)=>{if(!c.enabled)throw Error(`Tool ${r} is disabled`);let f;if(s){let _=c.inputSchema,w=_?await sa(_,p??{},`Invalid input for tool ${r}: `):p??{};f=await t(w,h)}else f=await t(h);return c.outputSchema&&!f.isError&&(f.structuredContent=await sa(c.outputSchema,f.structuredContent,`Invalid output for tool ${r}: `)),f}};return this._registeredTools[r]=c,!this._capabilities.tools&&!this.transport&&this.registerCapabilities({tools:{listChanged:!0}}),this.ensureToolHandlersInitialized(),a(),c}ensureToolHandlersInitialized(){this._toolHandlersInitialized||(this._toolHandlersInitialized=!0,this.oncalltool=async(r,o)=>{let t=this._registeredTools[r.name];if(!t)throw Error(`Tool ${r.name} not found`);return t.handler(r.arguments,o)},this.onlisttools=async(r,o)=>({tools:await Promise.all(Object.entries(this._registeredTools).filter(([t,i])=>i.enabled).map(async([t,i])=>{let a={name:t,title:i.title,description:i.description,inputSchema:i.inputSchema?await aa(i.inputSchema,"input"):{type:"object",properties:{}}};return i.outputSchema&&(a.outputSchema=await aa(i.outputSchema,"output")),i.annotations&&(a.annotations=i.annotations),i._meta&&(a._meta=i._meta),a}))}))}async sendToolListChanged(r={}){this._assertInitialized("sendToolListChanged"),await this.notification({method:"notifications/tools/list_changed",params:r})}getHostCapabilities(){return this._hostCapabilities}getHostVersion(){return this._hostInfo}getHostContext(){return this._hostContext}get ontoolinput(){return this.getEventHandler("toolinput")}set ontoolinput(r){this.setEventHandler("toolinput",r)}get ontoolinputpartial(){return this.getEventHandler("toolinputpartial")}set ontoolinputpartial(r){this.setEventHandler("toolinputpartial",r)}get ontoolresult(){return this.getEventHandler("toolresult")}set ontoolresult(r){this.setEventHandler("toolresult",r)}get ontoolcancelled(){return this.getEventHandler("toolcancelled")}set ontoolcancelled(r){this.setEventHandler("toolcancelled",r)}get onhostcontextchanged(){return this.getEventHandler("hostcontextchanged")}set onhostcontextchanged(r){this.setEventHandler("hostcontextchanged",r)}get onteardown(){return this._onteardown}set onteardown(r){this.warnIfRequestHandlerReplaced("onteardown",this._onteardown,r),this._onteardown=r,this.replaceRequestHandler(Ch,(o,t)=>{if(!this._onteardown)throw Error("No onteardown handler set");return this._onteardown(o.params,t)})}get oncalltool(){return this._oncalltool}set oncalltool(r){this.warnIfRequestHandlerReplaced("oncalltool",this._oncalltool,r),this._oncalltool=r,this.replaceRequestHandler(Zl,(o,t)=>{if(!this._oncalltool)throw Error("No oncalltool handler set");return this._oncalltool(o.params,t)})}get onlisttools(){return this._onlisttools}set onlisttools(r){this.warnIfRequestHandlerReplaced("onlisttools",this._onlisttools,r),this._onlisttools=r,this.replaceRequestHandler(Rl,(o,t)=>{if(!this._onlisttools)throw Error("No onlisttools handler set");return this._onlisttools(o.params,t)})}assertCapabilityForMethod(r){var o;switch(r){case"sampling/createMessage":if(!((o=this._hostCapabilities)!=null&&o.sampling))throw Error(`Host does not support sampling (required for ${r})`);break}}assertRequestHandlerCapability(r){switch(r){case"tools/call":case"tools/list":if(!this._capabilities.tools)throw Error(`Client does not support tool capability (required for ${r})`);return;case"ping":case"ui/resource-teardown":return;default:throw Error(`No handler for method ${r} registered`)}}assertNotificationCapability(r){}assertTaskCapability(r){throw Error("Tasks are not supported in MCP Apps")}assertTaskHandlerCapability(r){throw Error("Task handlers are not supported in MCP Apps")}async callServerTool(r,o){if(this._assertInitialized("callServerTool"),typeof r=="string")throw Error(`callServerTool() expects an object as its first argument, but received a string ("${r}"). Did you mean: callServerTool({ name: "${r}", arguments: { ... } })?`);return await this.request({method:"tools/call",params:r},er,{onprogress:()=>{},resetTimeoutOnProgress:!0,...o})}async readServerResource(r,o){return this._assertInitialized("readServerResource"),await this.request({method:"resources/read",params:r},El,o)}async listServerResources(r,o){return this._assertInitialized("listServerResources"),await this.request({method:"resources/list",params:r},Ul,o)}async createSamplingMessage(r,o){this._assertInitialized("createSamplingMessage");let t=r.tools?Ml:Ll;return await this.request({method:"sampling/createMessage",params:r},t,o)}sendMessage(r,o){return this._assertInitialized("sendMessage"),this.request({method:"ui/message",params:r},Oh,o)}sendLog(r){return this.notification({method:"notifications/message",params:r})}updateModelContext(r,o){return this._assertInitialized("updateModelContext"),this.request({method:"ui/update-model-context",params:r},go,o)}openLink(r,o){return this._assertInitialized("openLink"),this.request({method:"ui/open-link",params:r},jh,o)}downloadFile(r,o){return this._assertInitialized("downloadFile"),this.request({method:"ui/download-file",params:r},Nh,o)}requestTeardown(r={}){return this.notification({method:"ui/notifications/request-teardown",params:r})}requestDisplayMode(r,o){return this._assertInitialized("requestDisplayMode"),this.request({method:"ui/request-display-mode",params:r},Ah,o)}sendSizeChanged(r){return this.notification({method:"ui/notifications/size-changed",params:r})}setupSizeChangedNotifications(){let r=!1,o=0,t=0,i=()=>{r||(r=!0,requestAnimationFrame(()=>{r=!1;let s=document.documentElement,c=s.style.height;s.style.height="max-content";let p=Math.ceil(s.getBoundingClientRect().height);s.style.height=c;let h=Math.ceil(window.innerWidth);(h!==o||p!==t)&&(o=h,t=p,this.sendSizeChanged({width:h,height:p}))}))};i();let a=new ResizeObserver(i);return a.observe(document.documentElement),a.observe(document.body),()=>a.disconnect()}async connect(r=new wh(window.parent,window.parent),o){var t;if(this.transport)throw Error("App is already connected. Call close() before connecting again.");this._initializedSent=!1,await super.connect(r);try{let i=await this.request({method:"ui/initialize",params:{appCapabilities:this._capabilities,appInfo:this._appInfo,protocolVersion:kh}},Hh,o);if(i===void 0)throw Error(`Server sent invalid initialize result: ${i}`);this._hostCapabilities=i.hostCapabilities,this._hostInfo=i.hostInfo,this._hostContext=i.hostContext,await this.notification({method:"ui/notifications/initialized"}),this._initializedSent=!0,(t=this.options)!=null&&t.autoResize&&this.setupSizeChangedNotifications()}catch(i){throw this.close(),i}}};R(Kt,"ONE_SHOT_EVENTS",new Set(["toolinput","toolinputpartial","toolresult","toolcancelled"]));let fr=Kt;function ca(e){const n=e&&e.structuredContent;if(n&&typeof n=="object"){if(typeof n.snapshot=="string")try{return JSON.parse(n.snapshot)}catch{}return n}const r=(e&&e.content||[]).find(o=>o&&o.type==="text");if(r!=null&&r.text)try{return JSON.parse(r.text)}catch{return{}}return{}}class Wh{constructor(){R(this,"onhostcontextchanged");R(this,"onerror");R(this,"ontoolresult")}async connect(){var n,r;try{const[o,t]=await Promise.all([this.callServerTool({name:"project_config",arguments:{status:!0}}),this.callServerTool({name:"runtime",arguments:{action:"status"}})]),i=ca(o),a=ca(t),s=Array.isArray(i.projects)?i.projects:[],c={projects:s,projects_total:s.length,projects_ready:s.filter(p=>p&&p.ready).length,x_connected:!!i.x_connected,x_state:i.x_state||"",x_handle:i.x_handle??null,version:i.mcp_version||"",latest_version:i.latest_version??null,update_available:!!i.update_available,runtime_ready:typeof a.runtime_ready=="boolean"?a.runtime_ready:!0,runtime_provisioning:!!a.provisioning,onboarding:a.onboarding||i.onboarding};(n=this.ontoolresult)==null||n.call(this,{structuredContent:{snapshot:JSON.stringify(c)}})}catch(o){(r=this.onerror)==null||r.call(this,o)}}getHostContext(){}async callServerTool(n){const r=await fetch(`/tool/${encodeURIComponent(n.name)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n.arguments??{})});if(!r.ok){let o=`HTTP ${r.status}`;try{o=await r.text()||o}catch{}return{isError:!0,content:[{type:"text",text:o}]}}return r.json()}async sendMessage(){return{isError:!0}}}function Kh(){return globalThis.__S4L_BRIDGE__==="http"?new Wh:new fr({name:"S4L Panel",version:"1.0.0"})}function Hl(e){const n=e.structuredContent;if(n&&typeof n=="object"){if(typeof n.snapshot=="string")try{return JSON.parse(n.snapshot)}catch{}return n}const r=(e.content||[]).find(o=>o.type==="text");if(r!=null&&r.text)try{return JSON.parse(r.text)}catch{return{_raw:r.text}}return{}}const U=e=>document.getElementById(e),Fl=U("ver"),Xe=U("btn-setup"),Bt=U("btn-schedule"),Dt=U("stats-grid"),rr=U("stats-toggle"),Gh=U("log"),Qh=U("install-card"),Ne=U("setup-summary"),Jl=U("onboarding-details"),Yh=U("onboarding-steps"),ir=U("onboarding-blocker"),ua=U("onboarding-count"),Xh=U("onboarding-bar-fill"),eg=U("live-card"),tg=U("scanner-card"),Et=U("scanner-status"),hr=U("btn-scan-now"),ng=U("stats-card"),la=U("install-steps"),Ae=U("install-err"),be=U("btn-install"),rg=U("menubar-banner"),da=U("btn-menubar-restart"),Eo=U("btn-live"),Do=U("btn-live-stop"),ma=U("btn-live-front"),Le=U("live-status"),Vt=U("live-img"),Bl=U("switch-personal"),Vl=U("switch-promo"),_e=U("switch-pause"),ig=U("pause-name"),og=U("split-row"),ag=U("split-desc"),$e=U("split-slider"),Re=U("posting-volume-select"),sg=U("posting-volume-desc"),cg=U("mode-sub"),ug=U("settings-card"),or=U("settings-toggle"),fe=U("settings-body");let j=null,ar=!1,Ct=!1,sr=!1,ke=!1,tr=!1,Qe=!1,De=!1,cr=!1;function A(e){Gh.textContent=e}function lg(e){switch(e){case"done":return"✓";case"running":return"…";case"error":return"×";default:return"·"}}function Co(e){if(!e||!Array.isArray(e.steps)){la.innerHTML="";return}la.innerHTML=e.steps.map(n=>{const r=n.detail&&n.status!=="pending"?` <span class="detail">${n.status==="error"?n.detail:""}</span>`:"";return`<li class="${n.status}"><span class="glyph">${lg(n.status)}</span><span>${n.label}${r}</span></li>`}).join(""),e.error?(Ae.textContent=e.error,Ae.hidden=!1):Ae.hidden=!0}const dg={environment_checked:"Environment checked",runtime_ready:"Runtime ready",x_connected:"X connected",profile_scanned:"Profile scanned",project_ready:"Project ready",topics_seeded:"Topics seeded",tasks_scheduled:"Tasks scheduled",reddit_connected:"Reddit connected",reddit_verified:"Reddit verified"};function mg(e){switch(e){case"complete":return"✓";case"in_progress":return"…";case"blocked":return"×";default:return"·"}}function gr(){Jl.hidden=!ke,Ne.setAttribute("aria-expanded",String(ke)),Ne.classList.toggle("expanded",ke)}function Nt(e){return e?e.setup_complete!==void 0?!!e.setup_complete:!!e.runtime_ready&&(e.projects_ready||0)>0&&!!e.x_connected:!1}function pg(e){if(!e||!Array.isArray(e.milestones)){Ne.hidden=!0,Jl.hidden=!0;return}const n=Nt(j);Ne.hidden=n,n&&ke&&(ke=!1,gr());const r=e.milestones.length,o=e.milestones.filter(i=>i.status==="complete").length,t=!!e.current_blocker&&!n;Ne.classList.toggle("complete",n),Ne.classList.toggle("blocked",t),ua.hidden=n,ua.textContent=t?`${o}/${r} · needs you`:Ct?`${o}/${r} · setting up…`:`${o}/${r}`,Xh.style.width=r>0?`${Math.round(o/r*100)}%`:"0%",Yh.innerHTML=e.milestones.map(i=>{const a=dg[i.id]||i.id,s=i.attempts>1?` <span class="detail">${i.attempts} attempts</span>`:"";return`<li class="${i.status}"><span class="glyph">${mg(i.status)}</span><span>${a}${s}</span></li>`}).join(""),e.current_blocker?(ir.textContent=`Current blocker: ${e.current_blocker.message}`,ir.hidden=!1,ke=!0):ir.hidden=!0,gr()}function Wt(){if(!j)return;pg(j.onboarding),Fl.innerHTML=j.update_available&&j.latest_version?`v${j.version} · <button id="btn-update" class="update-btn">Update to ${j.latest_version}</button>`:`v${j.version}`;const e=!j.runtime_ready;Qh.hidden=!e,rg.hidden=e||j.menubar_running!==!1;const n=Nt(j);Xe.hidden=n,Xe.disabled=!1,Xe.classList.toggle("primary",!n);const r=n&&(j.schedule_state==="missing"||j.schedule_state==="disabled"||j.schedule_state==="stalled");Bt.hidden=!r,Bt.classList.toggle("primary",r);const o=j.flags||(j.mode==="promotion"?{personal_brand:!1,promotion:!0}:{personal_brand:!0,promotion:!1}),t=!!o.personal_brand,i=!!o.promotion;Bl.setAttribute("aria-checked",String(t)),Vl.setAttribute("aria-checked",String(i));const a=Math.round((typeof j.personal_brand_share=="number"?j.personal_brand_share:.5)*100);og.hidden=!(t&&i),tr||($e.value=String(a),Yl(a)),cg.textContent=t&&i?`Both lanes on: cycles split ${a}/${100-a} personal/promotion.`:!t&&!i?"No lane on; the cycle falls back to personal brand.":"";const s=j.paused===!0;_e.setAttribute("aria-checked",String(!s)),_e.setAttribute("aria-label",s?"Resume S4L":"Pause S4L"),_e.title=s?"Resume S4L":"Pause S4L",ig.textContent=s?"Paused":"Running",eg.hidden=!n,ng.hidden=!n,ug.hidden=e,Wl()}let vr=null;const fg={scanning:"Scanning",drafting:"Drafting",posting:"Posting"};function hg(e){const n=Math.max(0,Math.round(e));return n<60?`${n}s`:`${Math.floor(n/60)}:${String(n%60).padStart(2,"0")}`}function Wl(){if(!j)return;const e=Nt(j);if(tg.hidden=!e,!e)return;const n=j.activity_state||"idle",r=j.paused===!0,o=n==="scanning",t=fg[n];if(hr.disabled=r||o,t)Et.textContent=`⟳ ${t} now…`;else if(r)Et.textContent="Paused — resume S4L to run the scanner.";else if(vr!=null){const i=(vr-Date.now())/1e3;Et.textContent=i>0?`Next scan in ${hg(i)}`:"Next scan any moment…"}else Et.textContent="Next scan within a minute"}async function nr(){if(!(!j||!Nt(j)))try{const e=await K("scan_status");if(!e||typeof e!="object")return;vr=typeof e.next_run_secs=="number"?Date.now()+e.next_run_secs*1e3:null,ge({activity_state:e.activity_state,activity_label:e.activity_label,next_run_secs:typeof e.next_run_secs=="number"?e.next_run_secs:null,...typeof e.paused=="boolean"?{paused:e.paused}:{}})}catch{}}setInterval(()=>{j&&Nt(j)&&Wl()},1e3);setInterval(()=>{document.hidden||nr()},5e3);function ge(e){j={...j||{},...e},Wt()}function gg(e){const n=Array.isArray(e.projects)?e.projects:[];return{projects:n,projects_total:n.length,projects_ready:n.filter(r=>r.ready).length,x_connected:!!e.x_connected,x_state:e.x_state||"",x_handle:e.x_handle??null,...e.setup_complete!==void 0?{setup_complete:!!e.setup_complete}:{},version:e.mcp_version||(j==null?void 0:j.version)||"",latest_version:e.latest_version??null,update_available:!!e.update_available,mode:e.mode??(j==null?void 0:j.mode),flags:e.flags??(j==null?void 0:j.flags),personal_brand_share:e.personal_brand_share??(j==null?void 0:j.personal_brand_share),onboarding:e.onboarding}}const Ie=Kh();function Kl(e){var n,r,o;e.theme&&Jh(e.theme),(n=e.styles)!=null&&n.variables&&Bh(e.styles.variables),(o=(r=e.styles)==null?void 0:r.css)!=null&&o.fonts&&Vh(e.styles.css.fonts)}Ie.onhostcontextchanged=Kl;Ie.onerror=e=>console.error(e);Ie.ontoolresult=e=>{const n=Hl(e);n&&typeof n.projects_total=="number"&&(ge(n),n.runtime_ready?(Gl(),nr()):n.runtime_provisioning&&Ro())};async function K(e,n={}){const r=await Ie.callServerTool({name:e,arguments:n});return Hl(r)}async function ve(){A("Refreshing…");try{const[e,n]=await Promise.all([K("project_config",{status:!0}),K("runtime",{action:"status"}).catch(()=>({}))]);ge({...gg(e),...typeof n.runtime_ready=="boolean"?{runtime_ready:n.runtime_ready}:{},...typeof n.menubar_running=="boolean"?{menubar_running:n.menubar_running}:{},...typeof n.paused=="boolean"?{paused:n.paused}:{},onboarding:n.onboarding||e.onboarding||(j==null?void 0:j.onboarding)}),j&&!j.runtime_ready&&n.provisioning&&Ro(),A(""),Gl(),nr()}catch(e){A("Refresh failed: "+((e==null?void 0:e.message)||e))}}async function Ro(){if(!ar){ar=!0,be.disabled=!0,be.textContent="Installing…";try{for(;;){const e=await K("runtime",{action:"status"}).catch(()=>({}));if(Co(e.progress??null),e.onboarding&&ge({onboarding:e.onboarding}),e.runtime_ready){ge({runtime_ready:!0}),A("Runtime installed; you're ready to set up."),ve();return}const n=e.progress??null;if(n&&n.done&&!n.ok){be.disabled=!1,be.textContent="Retry install",A("Install failed; see the step above, then Retry.");return}await new Promise(r=>setTimeout(r,1500))}}finally{ar=!1}}}async function vg(){var r;if(Ct)return;Ct=!0,Wt();const e=Date.now(),n=1200*1e3;try{for(;;){const o=await K("runtime",{action:"status"}).catch(()=>({}));o.progress&&Co(o.progress);const t={};if(typeof o.runtime_ready=="boolean"&&(t.runtime_ready=o.runtime_ready),o.onboarding&&(t.onboarding=o.onboarding),Object.keys(t).length&&ge(t),(r=o.onboarding)!=null&&r.complete){await ve(),A("Setup complete.");break}if(Date.now()-e>n)break;await new Promise(i=>setTimeout(i,2e3))}}finally{Ct=!1,Wt()}}async function Gl(){try{const e=await K("get_stats",{days:7}),n=Array.isArray(e.projects)?e.projects[0]:null,r=n==null?void 0:n.posts;if(!r){Dt.innerHTML='<div class="muted">No stats yet.</div>';return}const o=[["Posts",r.total??0],["Active",r.active??0],["Views",r.views_period_total??r.views??0],["Replies",r.comments_period_total??r.comments??0],["Clicks",r.post_clicks_period_total??0]];Dt.innerHTML=o.map(([t,i])=>`<div class="stat"><div class="n">${i}</div><div class="l">${t}</div></div>`).join("")}catch(e){Dt.innerHTML=`<div class="muted">Stats unavailable: ${(e==null?void 0:e.message)||e}</div>`}}function Ot(e,n,r){const o=e.textContent;e.disabled=!0,e.textContent=n,r().finally(()=>{e.disabled=!1,e.textContent=o,Wt()})}Xe.addEventListener("click",()=>Ot(Xe,"Starting…",async()=>{A("Asking Claude to run setup…");try{const e=await Ie.sendMessage({role:"user",content:[{type:"text",text:"Set up S4L plugin end to end now. Inspect and repair the runtime, auto-detect and connect my X session, scan my profile, discover and research my product, then infer and save a complete project with seeded search topics. Keep going without asking me to approve each safe setup step. Ask only if I must interactively sign in or no product can be identified. Keep every reply to me extremely concise: a few short sentences at most, no step-by-step narration or long status walls. If you must ask me something (e.g. the product URL), make it one short question."}]});e!=null&&e.isError?A("The host rejected the setup request — type “set up S4L” in the chat instead."):(A("Setup is running in the chat. It will only stop for an unavoidable login or missing product."),vg())}catch(e){A("Couldn’t start setup: "+((e==null?void 0:e.message)||e))}}));hr.addEventListener("click",()=>Ot(hr,"Starting…",async()=>{try{const e=await K("scan_now");e!=null&&e.paused?A("S4L is paused — resume it first, then the scanner can run."):e!=null&&e.ok?A("Scanner started — new drafts land in a few minutes."):A("Couldn’t start the scanner: "+((e==null?void 0:e.detail)||"unknown error"))}catch(e){A("Couldn’t start the scanner: "+((e==null?void 0:e.message)||e))}await nr()}));Bt.addEventListener("click",()=>Ot(Bt,"Setting up…",async()=>{A("Asking Claude to schedule the draft tasks for this account…"),K("client_event",{event:"rearm_clicked",surface:"panel"}).catch(()=>{});try{const e=await Ie.sendMessage({role:"user",content:[{type:"text",text:`Set up the S4L draft autopilot schedule for this Claude account. If queue_setup is available, call it; then for s4l-worker call the host tool create_scheduled_task with taskId, cronExpression "* * * * *", notifyOnCompletion false (REQUIRED — the default true pops a notification every minute), and the prompt — read it from ~/.claude/scheduled-tasks/s4l-worker/SKILL.md (already on disk). If the task already exists, call update_scheduled_task with taskId s4l-worker and notifyOnCompletion false instead. Before calling create_scheduled_task/list_scheduled_tasks/update_scheduled_task, run (via Bash, best-effort, ignore any error and continue if the path doesn't exist): python3 ~/.social-autoposter-mcp/repo/package/scripts/mark_event.py start <that tool's name>; immediately after it returns, run the same with 'end' instead of 'start'. Do NOT redo my X connection or project setup. Keep replies to me very short.`}]});e!=null&&e.isError?A("The host rejected it — type “set up the draft schedule” in the chat instead."):A("Scheduling is running in the chat. The draft tasks will register for this account.")}catch(e){A("Couldn’t start scheduling: "+((e==null?void 0:e.message)||e))}}));function Ql(e,n){e.addEventListener("click",async()=>{if(!e.disabled){e.disabled=!0,e.setAttribute("aria-checked",String(e.getAttribute("aria-checked")!=="true"));try{const r=await K("engagement_mode",{action:"toggle",lane:n});r&&r.flags&&ge({flags:r.flags}),await ve()}catch(r){A("Couldn’t switch lane: "+((r==null?void 0:r.message)||r)),await ve()}finally{e.disabled=!1}}})}Ql(Bl,"personal_brand");Ql(Vl,"promotion");_e.addEventListener("click",async()=>{if(_e.disabled)return;const e=_e.getAttribute("aria-checked")!=="true",n=e?"resume":"pause";_e.disabled=!0,_e.setAttribute("aria-checked",String(e));try{const r=await K("pause_s4l",{action:n});A(r!=null&&r.ok?n==="pause"?"S4L paused — drafting and posting are stopped.":"S4L resumed.":`Couldn’t ${n} S4L${r!=null&&r.detail?": "+r.detail:"."}`),await ve()}catch(r){A(`Couldn’t ${n} S4L: `+((r==null?void 0:r.message)||r)),await ve()}finally{_e.disabled=!1}});function Yl(e){ag.textContent=`${e}% personal brand / ${100-e}% promotion`}$e.addEventListener("pointerdown",()=>{tr=!0});$e.addEventListener("input",()=>{tr=!0,Yl(Number($e.value))});$e.addEventListener("change",async()=>{const e=Number($e.value);$e.disabled=!0;try{const n=await K("engagement_mode",{action:"split",split:e});n&&typeof n.personal_brand_share=="number"&&ge({personal_brand_share:n.personal_brand_share}),await ve()}catch(n){A("Couldn’t set the lane split: "+((n==null?void 0:n.message)||n)),await ve()}finally{$e.disabled=!1,tr=!1}});let Xl=!1;function _g(e){if(!e||e.error)return;const n=e.mode||"medium";Re.value=n;const r=Array.isArray(e.rates)?e.rates:[],o={high:"Aggressive",medium:"Steady",low:"Chill"};for(const t of r){const i=Re.querySelector(`option[value="${t.mode}"]`);if(i&&t.est_posts_per_day!==null&&t.est_posts_per_day!==void 0){const a=Number(t.est_posts_per_day),s=o[t.mode]||t.mode;i.textContent=`${s} (~${a>=10?Math.round(a):a}/day)`}}sg.textContent="How many drafts per day the cycle produces."}async function _r(){try{const e=await K("posting_volume",{action:"get"});Xl=!0,_g(e)}catch{}}Re.addEventListener("change",async()=>{const e=Re.value;Re.disabled=!0;try{const n=await K("posting_volume",{action:"set",mode:e});n&&n.error?A("Couldn’t change posting volume: "+n.error):A(`Posting volume set to ${{high:"Aggressive",medium:"Steady",low:"Chill"}[e]||e}. Applies from the next cycle.`),await _r()}catch(n){A("Couldn’t change posting volume: "+((n==null?void 0:n.message)||n)),await _r()}finally{Re.disabled=!1}});Ne.addEventListener("click",()=>{ke=!ke,gr()});rr.addEventListener("click",()=>{Qe=!Qe,Dt.hidden=!Qe,rr.setAttribute("aria-expanded",String(Qe)),rr.classList.toggle("expanded",Qe)});Fl.addEventListener("click",e=>{const n=e.target;n&&n.id==="btn-update"&&bg()});async function bg(){if(sr)return;sr=!0;const e=document.getElementById("btn-update");e&&(e.disabled=!0,e.textContent="Updating…"),A("Installing the latest release… this can take a minute.");try{const n=await K("runtime",{action:"update"});n.ok?(A(`Updated to ${n.latest_published||"the latest version"}. ${n.takes_effect||"Restart the client to apply."}`),e&&(e.textContent="Update installed — restart to apply")):(A("Update failed (exit "+(n.exit_code??"?")+"). Try `npx social-autoposter@latest update` in a terminal."),e&&(e.disabled=!1,e.textContent="Retry update"))}catch(n){A("Update failed: "+((n==null?void 0:n.message)||n)),e&&(e.disabled=!1,e.textContent="Retry update")}finally{sr=!1}}be.addEventListener("click",async()=>{Ae.hidden=!0,be.disabled=!0,be.textContent="Starting…",A("Installing the runtime — this is a one-time download (~150MB+).");try{const e=await K("runtime",{action:"install"});if(e.runtime_ready){ge({runtime_ready:!0}),ve();return}Co(e.progress??null),Ro()}catch(e){be.disabled=!1,be.textContent="Retry install",Ae.textContent="Couldn't start install: "+((e==null?void 0:e.message)||e),Ae.hidden=!1}});da.addEventListener("click",()=>Ot(da,"Restarting…",async()=>{A("Restarting the S4L menu bar…");try{const e=await K("restart_menubar");typeof e.menubar_running=="boolean"&&ge({menubar_running:e.menubar_running}),A(e.menubar_running?"Menu bar restarted.":"Couldn’t confirm the menu bar came back"+(e.detail?": "+e.detail:"."))}catch(e){A("Couldn’t restart the menu bar: "+((e==null?void 0:e.message)||e))}}));const yg=["website","description","icp","voice","differentiator","search_topics","get_started_link","content_guardrails"],$g=["description","voice","search_topics","content_angle","content_guardrails"],br={website:"Website",description:"What it does",icp:"Target audience",voice:"Voice",differentiator:"Differentiator",search_topics:"Search topics",get_started_link:"Get-started link",content_guardrails:"Content guardrails",content_angle:"Content angle"},pa=new Set(["website","description","icp","voice","differentiator","search_topics","get_started_link","content_guardrails"]),kg=new Set(["website","get_started_link"]);function et(e){const n=e.replace(/[_-]+/g," ").trim();return n?n.charAt(0).toUpperCase()+n.slice(1):e}function ed(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function fa(e){return Array.isArray(e)&&e.every(n=>typeof n=="string")?{kind:"list",text:e.join(`
73
+ container holding the app. Specify either width or maxWidth, and either height or maxHeight.`),locale:d().optional().describe("User's language and region preference in BCP 47 format."),timeZone:d().optional().describe("User's timezone in IANA format."),userAgent:d().optional().describe("Host application identifier."),platform:D([u("web"),u("desktop"),u("mobile")]).optional().describe("Platform type for responsive design decisions."),deviceCapabilities:m({touch:F().optional().describe("Whether the device supports touch input."),hover:F().optional().describe("Whether the device supports hover interactions.")}).optional().describe("Device input capabilities."),safeAreaInsets:m({top:T().describe("Top safe area inset in pixels."),right:T().describe("Right safe area inset in pixels."),bottom:T().describe("Bottom safe area inset in pixels."),left:T().describe("Left safe area inset in pixels.")}).optional().describe("Mobile safe area boundaries in pixels.")}).passthrough(),qh=m({method:u("ui/notifications/host-context-changed"),params:ql.describe("Partial context update containing only changed fields.")});m({method:u("ui/update-model-context"),params:m({content:x(jt).optional().describe("Context content blocks (text, image, etc.)."),structuredContent:L(d(),J().describe("Structured content for machine-readable context data.")).optional().describe("Structured content for machine-readable context data.")})});m({method:u("ui/initialize"),params:m({appInfo:Yn.describe("App identification (name and version)."),appCapabilities:Zh.describe("Features and capabilities this app provides."),protocolVersion:d().describe("Protocol version this app supports.")})});var Hh=m({protocolVersion:d().describe('Negotiated protocol version string (e.g., "2025-11-21").'),hostInfo:Yn.describe("Host application identification and version."),hostCapabilities:Rh.describe("Features and capabilities provided by the host."),hostContext:ql.describe("Rich context about the host environment.")}).passthrough(),Fh={target:"draft-2020-12"};async function aa(e,n){let r=e["~standard"];if(r.jsonSchema)return r.jsonSchema[n](Fh);if(r.vendor==="zod"){let{z:o}=await ad(()=>Promise.resolve().then(()=>Tp),void 0,import.meta.url);return o.toJSONSchema(e,{io:n})}throw Error(`Schema (vendor: ${r.vendor}) does not implement Standard JSON Schema (~standard.jsonSchema). Use a library that does (zod v4, ArkType, Valibot) or wrap your schema accordingly.`)}async function sa(e,n,r=""){let o=await e["~standard"].validate(n);if(o.issues){let t=o.issues.map(i=>{var s;let a=(s=i.path)==null?void 0:s.map(c=>typeof c=="object"?c.key:c).join(".");return a?`${a}: ${i.message}`:i.message}).join("; ");throw Error(r+t)}return o.value}function Jh(e){let n=document.documentElement;n.setAttribute("data-theme",e),n.style.colorScheme=e}function Bh(e,n=document.documentElement){for(let[r,o]of Object.entries(e))o!==void 0&&n.style.setProperty(r,o)}function Vh(e){if(document.getElementById("__mcp-host-fonts"))return;let n=document.createElement("style");n.id="__mcp-host-fonts",n.textContent=e,document.head.appendChild(n)}const Kt=class Kt extends $h{constructor(r,o={},t={autoResize:!0}){super(t);R(this,"_appInfo");R(this,"_capabilities");R(this,"options");R(this,"_hostCapabilities");R(this,"_hostInfo");R(this,"_hostContext");R(this,"_registeredTools",{});R(this,"_initializedSent",!1);R(this,"eventSchemas",{toolinput:Th,toolinputpartial:Ph,toolresult:Mh,toolcancelled:Uh,hostcontextchanged:qh});R(this,"_everHadListener",new Set);R(this,"_toolHandlersInitialized",!1);R(this,"_onteardown");R(this,"_oncalltool");R(this,"_onlisttools");R(this,"sendOpenLink",this.openLink);this._appInfo=r,this._capabilities=o,this.options=t,t.allowUnsafeEval||ee({jitless:!0}),this.setRequestHandler(Xn,i=>(console.log("Received ping:",i.params),{})),this.setEventHandler("hostcontextchanged",void 0)}_assertInitialized(r){var t;if(this._initializedSent)return;let o=`[ext-apps] App.${r}() called before connect() completed the ui/initialize handshake. Await app.connect() before calling this method, or move data loading to an ontoolresult handler.`;if((t=this.options)!=null&&t.strict)throw Error(o);console.warn(`${o}. This will throw in a future release.`)}_assertHandlerTiming(r){var t;if(!Kt.ONE_SHOT_EVENTS.has(r)||this._everHadListener.has(r)||(this._everHadListener.add(r),!this._initializedSent))return;let o=`[ext-apps] "${String(r)}" handler registered after connect() completed the ui/initialize handshake. The host may have already sent this notification. Register handlers before calling app.connect().`;if((t=this.options)!=null&&t.strict)throw Error(o);console.warn(o)}setEventHandler(r,o){o&&this._assertHandlerTiming(r),super.setEventHandler(r,o)}addEventListener(r,o){this._assertHandlerTiming(r),super.addEventListener(r,o)}onEventDispatch(r,o){r==="hostcontextchanged"&&(this._hostContext={...this._hostContext,...o})}registerCapabilities(r){if(this.transport)throw Error("Cannot register capabilities after transport is established");this._capabilities=yh(this._capabilities,r)}registerTool(r,o,t){if(this._registeredTools[r])throw Error(`Tool ${r} is already registered`);let i=this,a=()=>{var p;i._initializedSent&&((p=i._capabilities.tools)!=null&&p.listChanged)&&i.sendToolListChanged()},s=o.inputSchema!==void 0,c={title:o.title,description:o.description,inputSchema:o.inputSchema,outputSchema:o.outputSchema,annotations:o.annotations,_meta:o._meta,enabled:!0,enable(){this.enabled=!0,a()},disable(){this.enabled=!1,a()},update(p){Object.assign(this,p),a()},remove(){i._registeredTools[r]===c&&(delete i._registeredTools[r],a())},handler:async(p,h)=>{if(!c.enabled)throw Error(`Tool ${r} is disabled`);let f;if(s){let _=c.inputSchema,w=_?await sa(_,p??{},`Invalid input for tool ${r}: `):p??{};f=await t(w,h)}else f=await t(h);return c.outputSchema&&!f.isError&&(f.structuredContent=await sa(c.outputSchema,f.structuredContent,`Invalid output for tool ${r}: `)),f}};return this._registeredTools[r]=c,!this._capabilities.tools&&!this.transport&&this.registerCapabilities({tools:{listChanged:!0}}),this.ensureToolHandlersInitialized(),a(),c}ensureToolHandlersInitialized(){this._toolHandlersInitialized||(this._toolHandlersInitialized=!0,this.oncalltool=async(r,o)=>{let t=this._registeredTools[r.name];if(!t)throw Error(`Tool ${r.name} not found`);return t.handler(r.arguments,o)},this.onlisttools=async(r,o)=>({tools:await Promise.all(Object.entries(this._registeredTools).filter(([t,i])=>i.enabled).map(async([t,i])=>{let a={name:t,title:i.title,description:i.description,inputSchema:i.inputSchema?await aa(i.inputSchema,"input"):{type:"object",properties:{}}};return i.outputSchema&&(a.outputSchema=await aa(i.outputSchema,"output")),i.annotations&&(a.annotations=i.annotations),i._meta&&(a._meta=i._meta),a}))}))}async sendToolListChanged(r={}){this._assertInitialized("sendToolListChanged"),await this.notification({method:"notifications/tools/list_changed",params:r})}getHostCapabilities(){return this._hostCapabilities}getHostVersion(){return this._hostInfo}getHostContext(){return this._hostContext}get ontoolinput(){return this.getEventHandler("toolinput")}set ontoolinput(r){this.setEventHandler("toolinput",r)}get ontoolinputpartial(){return this.getEventHandler("toolinputpartial")}set ontoolinputpartial(r){this.setEventHandler("toolinputpartial",r)}get ontoolresult(){return this.getEventHandler("toolresult")}set ontoolresult(r){this.setEventHandler("toolresult",r)}get ontoolcancelled(){return this.getEventHandler("toolcancelled")}set ontoolcancelled(r){this.setEventHandler("toolcancelled",r)}get onhostcontextchanged(){return this.getEventHandler("hostcontextchanged")}set onhostcontextchanged(r){this.setEventHandler("hostcontextchanged",r)}get onteardown(){return this._onteardown}set onteardown(r){this.warnIfRequestHandlerReplaced("onteardown",this._onteardown,r),this._onteardown=r,this.replaceRequestHandler(Ch,(o,t)=>{if(!this._onteardown)throw Error("No onteardown handler set");return this._onteardown(o.params,t)})}get oncalltool(){return this._oncalltool}set oncalltool(r){this.warnIfRequestHandlerReplaced("oncalltool",this._oncalltool,r),this._oncalltool=r,this.replaceRequestHandler(Zl,(o,t)=>{if(!this._oncalltool)throw Error("No oncalltool handler set");return this._oncalltool(o.params,t)})}get onlisttools(){return this._onlisttools}set onlisttools(r){this.warnIfRequestHandlerReplaced("onlisttools",this._onlisttools,r),this._onlisttools=r,this.replaceRequestHandler(Rl,(o,t)=>{if(!this._onlisttools)throw Error("No onlisttools handler set");return this._onlisttools(o.params,t)})}assertCapabilityForMethod(r){var o;switch(r){case"sampling/createMessage":if(!((o=this._hostCapabilities)!=null&&o.sampling))throw Error(`Host does not support sampling (required for ${r})`);break}}assertRequestHandlerCapability(r){switch(r){case"tools/call":case"tools/list":if(!this._capabilities.tools)throw Error(`Client does not support tool capability (required for ${r})`);return;case"ping":case"ui/resource-teardown":return;default:throw Error(`No handler for method ${r} registered`)}}assertNotificationCapability(r){}assertTaskCapability(r){throw Error("Tasks are not supported in MCP Apps")}assertTaskHandlerCapability(r){throw Error("Task handlers are not supported in MCP Apps")}async callServerTool(r,o){if(this._assertInitialized("callServerTool"),typeof r=="string")throw Error(`callServerTool() expects an object as its first argument, but received a string ("${r}"). Did you mean: callServerTool({ name: "${r}", arguments: { ... } })?`);return await this.request({method:"tools/call",params:r},er,{onprogress:()=>{},resetTimeoutOnProgress:!0,...o})}async readServerResource(r,o){return this._assertInitialized("readServerResource"),await this.request({method:"resources/read",params:r},El,o)}async listServerResources(r,o){return this._assertInitialized("listServerResources"),await this.request({method:"resources/list",params:r},Ul,o)}async createSamplingMessage(r,o){this._assertInitialized("createSamplingMessage");let t=r.tools?Ml:Ll;return await this.request({method:"sampling/createMessage",params:r},t,o)}sendMessage(r,o){return this._assertInitialized("sendMessage"),this.request({method:"ui/message",params:r},Oh,o)}sendLog(r){return this.notification({method:"notifications/message",params:r})}updateModelContext(r,o){return this._assertInitialized("updateModelContext"),this.request({method:"ui/update-model-context",params:r},go,o)}openLink(r,o){return this._assertInitialized("openLink"),this.request({method:"ui/open-link",params:r},jh,o)}downloadFile(r,o){return this._assertInitialized("downloadFile"),this.request({method:"ui/download-file",params:r},Nh,o)}requestTeardown(r={}){return this.notification({method:"ui/notifications/request-teardown",params:r})}requestDisplayMode(r,o){return this._assertInitialized("requestDisplayMode"),this.request({method:"ui/request-display-mode",params:r},Ah,o)}sendSizeChanged(r){return this.notification({method:"ui/notifications/size-changed",params:r})}setupSizeChangedNotifications(){let r=!1,o=0,t=0,i=()=>{r||(r=!0,requestAnimationFrame(()=>{r=!1;let s=document.documentElement,c=s.style.height;s.style.height="max-content";let p=Math.ceil(s.getBoundingClientRect().height);s.style.height=c;let h=Math.ceil(window.innerWidth);(h!==o||p!==t)&&(o=h,t=p,this.sendSizeChanged({width:h,height:p}))}))};i();let a=new ResizeObserver(i);return a.observe(document.documentElement),a.observe(document.body),()=>a.disconnect()}async connect(r=new wh(window.parent,window.parent),o){var t;if(this.transport)throw Error("App is already connected. Call close() before connecting again.");this._initializedSent=!1,await super.connect(r);try{let i=await this.request({method:"ui/initialize",params:{appCapabilities:this._capabilities,appInfo:this._appInfo,protocolVersion:kh}},Hh,o);if(i===void 0)throw Error(`Server sent invalid initialize result: ${i}`);this._hostCapabilities=i.hostCapabilities,this._hostInfo=i.hostInfo,this._hostContext=i.hostContext,await this.notification({method:"ui/notifications/initialized"}),this._initializedSent=!0,(t=this.options)!=null&&t.autoResize&&this.setupSizeChangedNotifications()}catch(i){throw this.close(),i}}};R(Kt,"ONE_SHOT_EVENTS",new Set(["toolinput","toolinputpartial","toolresult","toolcancelled"]));let fr=Kt;function ca(e){const n=e&&e.structuredContent;if(n&&typeof n=="object"){if(typeof n.snapshot=="string")try{return JSON.parse(n.snapshot)}catch{}return n}const r=(e&&e.content||[]).find(o=>o&&o.type==="text");if(r!=null&&r.text)try{return JSON.parse(r.text)}catch{return{}}return{}}class Wh{constructor(){R(this,"onhostcontextchanged");R(this,"onerror");R(this,"ontoolresult")}async connect(){var n,r;try{const[o,t]=await Promise.all([this.callServerTool({name:"project_config",arguments:{status:!0}}),this.callServerTool({name:"runtime",arguments:{action:"status"}})]),i=ca(o),a=ca(t),s=Array.isArray(i.projects)?i.projects:[],c={projects:s,projects_total:s.length,projects_ready:s.filter(p=>p&&p.ready).length,x_connected:!!i.x_connected,x_state:i.x_state||"",x_handle:i.x_handle??null,version:i.mcp_version||"",latest_version:i.latest_version??null,update_available:!!i.update_available,runtime_ready:typeof a.runtime_ready=="boolean"?a.runtime_ready:!0,runtime_provisioning:!!a.provisioning,onboarding:a.onboarding||i.onboarding};(n=this.ontoolresult)==null||n.call(this,{structuredContent:{snapshot:JSON.stringify(c)}})}catch(o){(r=this.onerror)==null||r.call(this,o)}}getHostContext(){}async callServerTool(n){const r=await fetch(`/tool/${encodeURIComponent(n.name)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n.arguments??{})});if(!r.ok){let o=`HTTP ${r.status}`;try{o=await r.text()||o}catch{}return{isError:!0,content:[{type:"text",text:o}]}}return r.json()}async sendMessage(){return{isError:!0}}}function Kh(){return globalThis.__S4L_BRIDGE__==="http"?new Wh:new fr({name:"S4L Panel",version:"1.0.0"})}function Hl(e){const n=e.structuredContent;if(n&&typeof n=="object"){if(typeof n.snapshot=="string")try{return JSON.parse(n.snapshot)}catch{}return n}const r=(e.content||[]).find(o=>o.type==="text");if(r!=null&&r.text)try{return JSON.parse(r.text)}catch{return{_raw:r.text}}return{}}const U=e=>document.getElementById(e),Fl=U("ver"),Xe=U("btn-setup"),Bt=U("btn-schedule"),Dt=U("stats-grid"),rr=U("stats-toggle"),Gh=U("log"),Qh=U("install-card"),Ne=U("setup-summary"),Jl=U("onboarding-details"),Yh=U("onboarding-steps"),ir=U("onboarding-blocker"),ua=U("onboarding-count"),Xh=U("onboarding-bar-fill"),eg=U("live-card"),tg=U("drafting-card"),Et=U("drafting-status"),hr=U("btn-run-drafting"),ng=U("stats-card"),la=U("install-steps"),Ae=U("install-err"),be=U("btn-install"),rg=U("menubar-banner"),da=U("btn-menubar-restart"),Eo=U("btn-live"),Do=U("btn-live-stop"),ma=U("btn-live-front"),Le=U("live-status"),Vt=U("live-img"),Bl=U("switch-personal"),Vl=U("switch-promo"),_e=U("switch-pause"),ig=U("pause-name"),og=U("split-row"),ag=U("split-desc"),$e=U("split-slider"),Re=U("posting-volume-select"),sg=U("posting-volume-desc"),cg=U("mode-sub"),ug=U("settings-card"),or=U("settings-toggle"),fe=U("settings-body");let j=null,ar=!1,Ct=!1,sr=!1,ke=!1,tr=!1,Qe=!1,De=!1,cr=!1;function A(e){Gh.textContent=e}function lg(e){switch(e){case"done":return"✓";case"running":return"…";case"error":return"×";default:return"·"}}function Co(e){if(!e||!Array.isArray(e.steps)){la.innerHTML="";return}la.innerHTML=e.steps.map(n=>{const r=n.detail&&n.status!=="pending"?` <span class="detail">${n.status==="error"?n.detail:""}</span>`:"";return`<li class="${n.status}"><span class="glyph">${lg(n.status)}</span><span>${n.label}${r}</span></li>`}).join(""),e.error?(Ae.textContent=e.error,Ae.hidden=!1):Ae.hidden=!0}const dg={environment_checked:"Environment checked",runtime_ready:"Runtime ready",x_connected:"X connected",profile_scanned:"Profile scanned",project_ready:"Project ready",topics_seeded:"Topics seeded",tasks_scheduled:"Tasks scheduled",reddit_connected:"Reddit connected",reddit_verified:"Reddit verified"};function mg(e){switch(e){case"complete":return"✓";case"in_progress":return"…";case"blocked":return"×";default:return"·"}}function gr(){Jl.hidden=!ke,Ne.setAttribute("aria-expanded",String(ke)),Ne.classList.toggle("expanded",ke)}function Nt(e){return e?e.setup_complete!==void 0?!!e.setup_complete:!!e.runtime_ready&&(e.projects_ready||0)>0&&!!e.x_connected:!1}function pg(e){if(!e||!Array.isArray(e.milestones)){Ne.hidden=!0,Jl.hidden=!0;return}const n=Nt(j);Ne.hidden=n,n&&ke&&(ke=!1,gr());const r=e.milestones.length,o=e.milestones.filter(i=>i.status==="complete").length,t=!!e.current_blocker&&!n;Ne.classList.toggle("complete",n),Ne.classList.toggle("blocked",t),ua.hidden=n,ua.textContent=t?`${o}/${r} · needs you`:Ct?`${o}/${r} · setting up…`:`${o}/${r}`,Xh.style.width=r>0?`${Math.round(o/r*100)}%`:"0%",Yh.innerHTML=e.milestones.map(i=>{const a=dg[i.id]||i.id,s=i.attempts>1?` <span class="detail">${i.attempts} attempts</span>`:"";return`<li class="${i.status}"><span class="glyph">${mg(i.status)}</span><span>${a}${s}</span></li>`}).join(""),e.current_blocker?(ir.textContent=`Current blocker: ${e.current_blocker.message}`,ir.hidden=!1,ke=!0):ir.hidden=!0,gr()}function Wt(){if(!j)return;pg(j.onboarding),Fl.innerHTML=j.update_available&&j.latest_version?`v${j.version} · <button id="btn-update" class="update-btn">Update to ${j.latest_version}</button>`:`v${j.version}`;const e=!j.runtime_ready;Qh.hidden=!e,rg.hidden=e||j.menubar_running!==!1;const n=Nt(j);Xe.hidden=n,Xe.disabled=!1,Xe.classList.toggle("primary",!n);const r=n&&(j.schedule_state==="missing"||j.schedule_state==="disabled"||j.schedule_state==="stalled");Bt.hidden=!r,Bt.classList.toggle("primary",r);const o=j.flags||(j.mode==="promotion"?{personal_brand:!1,promotion:!0}:{personal_brand:!0,promotion:!1}),t=!!o.personal_brand,i=!!o.promotion;Bl.setAttribute("aria-checked",String(t)),Vl.setAttribute("aria-checked",String(i));const a=Math.round((typeof j.personal_brand_share=="number"?j.personal_brand_share:.5)*100);og.hidden=!(t&&i),tr||($e.value=String(a),Yl(a)),cg.textContent=t&&i?`Both lanes on: cycles split ${a}/${100-a} personal/promotion.`:!t&&!i?"No lane on; the cycle falls back to personal brand.":"";const s=j.paused===!0;_e.setAttribute("aria-checked",String(!s)),_e.setAttribute("aria-label",s?"Resume S4L":"Pause S4L"),_e.title=s?"Resume S4L":"Pause S4L",ig.textContent=s?"Paused":"Running",eg.hidden=!n,ng.hidden=!n,ug.hidden=e,Wl()}let vr=null;const fg={scanning:"Scanning",drafting:"Drafting",posting:"Posting"};function hg(e){const n=Math.max(0,Math.round(e));return n<60?`${n}s`:`${Math.floor(n/60)}:${String(n%60).padStart(2,"0")}`}function Wl(){if(!j)return;const e=Nt(j);if(tg.hidden=!e,!e)return;const n=j.activity_state||"idle",r=j.paused===!0,o=n==="scanning"||n==="drafting"||n==="posting",t=fg[n];if(hr.disabled=r||o,t)Et.textContent=`⟳ ${t} now…`;else if(r)Et.textContent="Paused — resume S4L to run drafting.";else if(vr!=null){const i=(vr-Date.now())/1e3;Et.textContent=i>0?`Next drafting run in ${hg(i)}`:"Next drafting run any moment…"}else Et.textContent="Next drafting run within a minute"}async function nr(){if(!(!j||!Nt(j)))try{const e=await K("drafting_status");if(!e||typeof e!="object")return;vr=typeof e.next_run_secs=="number"?Date.now()+e.next_run_secs*1e3:null,ge({activity_state:e.activity_state,activity_label:e.activity_label,next_run_secs:typeof e.next_run_secs=="number"?e.next_run_secs:null,...typeof e.paused=="boolean"?{paused:e.paused}:{}})}catch{}}setInterval(()=>{j&&Nt(j)&&Wl()},1e3);setInterval(()=>{document.hidden||nr()},5e3);function ge(e){j={...j||{},...e},Wt()}function gg(e){const n=Array.isArray(e.projects)?e.projects:[];return{projects:n,projects_total:n.length,projects_ready:n.filter(r=>r.ready).length,x_connected:!!e.x_connected,x_state:e.x_state||"",x_handle:e.x_handle??null,...e.setup_complete!==void 0?{setup_complete:!!e.setup_complete}:{},version:e.mcp_version||(j==null?void 0:j.version)||"",latest_version:e.latest_version??null,update_available:!!e.update_available,mode:e.mode??(j==null?void 0:j.mode),flags:e.flags??(j==null?void 0:j.flags),personal_brand_share:e.personal_brand_share??(j==null?void 0:j.personal_brand_share),onboarding:e.onboarding}}const Ie=Kh();function Kl(e){var n,r,o;e.theme&&Jh(e.theme),(n=e.styles)!=null&&n.variables&&Bh(e.styles.variables),(o=(r=e.styles)==null?void 0:r.css)!=null&&o.fonts&&Vh(e.styles.css.fonts)}Ie.onhostcontextchanged=Kl;Ie.onerror=e=>console.error(e);Ie.ontoolresult=e=>{const n=Hl(e);n&&typeof n.projects_total=="number"&&(ge(n),n.runtime_ready?(Gl(),nr()):n.runtime_provisioning&&Ro())};async function K(e,n={}){const r=await Ie.callServerTool({name:e,arguments:n});return Hl(r)}async function ve(){A("Refreshing…");try{const[e,n]=await Promise.all([K("project_config",{status:!0}),K("runtime",{action:"status"}).catch(()=>({}))]);ge({...gg(e),...typeof n.runtime_ready=="boolean"?{runtime_ready:n.runtime_ready}:{},...typeof n.menubar_running=="boolean"?{menubar_running:n.menubar_running}:{},...typeof n.paused=="boolean"?{paused:n.paused}:{},onboarding:n.onboarding||e.onboarding||(j==null?void 0:j.onboarding)}),j&&!j.runtime_ready&&n.provisioning&&Ro(),A(""),Gl(),nr()}catch(e){A("Refresh failed: "+((e==null?void 0:e.message)||e))}}async function Ro(){if(!ar){ar=!0,be.disabled=!0,be.textContent="Installing…";try{for(;;){const e=await K("runtime",{action:"status"}).catch(()=>({}));if(Co(e.progress??null),e.onboarding&&ge({onboarding:e.onboarding}),e.runtime_ready){ge({runtime_ready:!0}),A("Runtime installed; you're ready to set up."),ve();return}const n=e.progress??null;if(n&&n.done&&!n.ok){be.disabled=!1,be.textContent="Retry install",A("Install failed; see the step above, then Retry.");return}await new Promise(r=>setTimeout(r,1500))}}finally{ar=!1}}}async function vg(){var r;if(Ct)return;Ct=!0,Wt();const e=Date.now(),n=1200*1e3;try{for(;;){const o=await K("runtime",{action:"status"}).catch(()=>({}));o.progress&&Co(o.progress);const t={};if(typeof o.runtime_ready=="boolean"&&(t.runtime_ready=o.runtime_ready),o.onboarding&&(t.onboarding=o.onboarding),Object.keys(t).length&&ge(t),(r=o.onboarding)!=null&&r.complete){await ve(),A("Setup complete.");break}if(Date.now()-e>n)break;await new Promise(i=>setTimeout(i,2e3))}}finally{Ct=!1,Wt()}}async function Gl(){try{const e=await K("get_stats",{days:7}),n=Array.isArray(e.projects)?e.projects[0]:null,r=n==null?void 0:n.posts;if(!r){Dt.innerHTML='<div class="muted">No stats yet.</div>';return}const o=[["Posts",r.total??0],["Active",r.active??0],["Views",r.views_period_total??r.views??0],["Replies",r.comments_period_total??r.comments??0],["Clicks",r.post_clicks_period_total??0]];Dt.innerHTML=o.map(([t,i])=>`<div class="stat"><div class="n">${i}</div><div class="l">${t}</div></div>`).join("")}catch(e){Dt.innerHTML=`<div class="muted">Stats unavailable: ${(e==null?void 0:e.message)||e}</div>`}}function Ot(e,n,r){const o=e.textContent;e.disabled=!0,e.textContent=n,r().finally(()=>{e.disabled=!1,e.textContent=o,Wt()})}Xe.addEventListener("click",()=>Ot(Xe,"Starting…",async()=>{A("Asking Claude to run setup…");try{const e=await Ie.sendMessage({role:"user",content:[{type:"text",text:"Set up S4L plugin end to end now. Inspect and repair the runtime, auto-detect and connect my X session, scan my profile, discover and research my product, then infer and save a complete project with seeded search topics. Keep going without asking me to approve each safe setup step. Ask only if I must interactively sign in or no product can be identified. Keep every reply to me extremely concise: a few short sentences at most, no step-by-step narration or long status walls. If you must ask me something (e.g. the product URL), make it one short question."}]});e!=null&&e.isError?A("The host rejected the setup request — type “set up S4L” in the chat instead."):(A("Setup is running in the chat. It will only stop for an unavoidable login or missing product."),vg())}catch(e){A("Couldn’t start setup: "+((e==null?void 0:e.message)||e))}}));hr.addEventListener("click",()=>Ot(hr,"Starting…",async()=>{try{const e=await K("run_drafting");e!=null&&e.paused?A("S4L is paused — resume it first, then drafting can run."):e!=null&&e.ok?A("Drafting started — new drafts land in a few minutes."):A("Couldn’t start drafting: "+((e==null?void 0:e.detail)||"unknown error"))}catch(e){A("Couldn’t start drafting: "+((e==null?void 0:e.message)||e))}await nr()}));Bt.addEventListener("click",()=>Ot(Bt,"Setting up…",async()=>{A("Asking Claude to schedule the draft tasks for this account…"),K("client_event",{event:"rearm_clicked",surface:"panel"}).catch(()=>{});try{const e=await Ie.sendMessage({role:"user",content:[{type:"text",text:`Set up the S4L draft autopilot schedule for this Claude account. If queue_setup is available, call it; then for s4l-worker call the host tool create_scheduled_task with taskId, cronExpression "* * * * *", notifyOnCompletion false (REQUIRED — the default true pops a notification every minute), and the prompt — read it from ~/.claude/scheduled-tasks/s4l-worker/SKILL.md (already on disk). If the task already exists, call update_scheduled_task with taskId s4l-worker and notifyOnCompletion false instead. Before calling create_scheduled_task/list_scheduled_tasks/update_scheduled_task, run (via Bash, best-effort, ignore any error and continue if the path doesn't exist): python3 ~/.social-autoposter-mcp/repo/package/scripts/mark_event.py start <that tool's name>; immediately after it returns, run the same with 'end' instead of 'start'. Do NOT redo my X connection or project setup. Keep replies to me very short.`}]});e!=null&&e.isError?A("The host rejected it — type “set up the draft schedule” in the chat instead."):A("Scheduling is running in the chat. The draft tasks will register for this account.")}catch(e){A("Couldn’t start scheduling: "+((e==null?void 0:e.message)||e))}}));function Ql(e,n){e.addEventListener("click",async()=>{if(!e.disabled){e.disabled=!0,e.setAttribute("aria-checked",String(e.getAttribute("aria-checked")!=="true"));try{const r=await K("engagement_mode",{action:"toggle",lane:n});r&&r.flags&&ge({flags:r.flags}),await ve()}catch(r){A("Couldn’t switch lane: "+((r==null?void 0:r.message)||r)),await ve()}finally{e.disabled=!1}}})}Ql(Bl,"personal_brand");Ql(Vl,"promotion");_e.addEventListener("click",async()=>{if(_e.disabled)return;const e=_e.getAttribute("aria-checked")!=="true",n=e?"resume":"pause";_e.disabled=!0,_e.setAttribute("aria-checked",String(e));try{const r=await K("pause_s4l",{action:n});A(r!=null&&r.ok?n==="pause"?"S4L paused — drafting and posting are stopped.":"S4L resumed.":`Couldn’t ${n} S4L${r!=null&&r.detail?": "+r.detail:"."}`),await ve()}catch(r){A(`Couldn’t ${n} S4L: `+((r==null?void 0:r.message)||r)),await ve()}finally{_e.disabled=!1}});function Yl(e){ag.textContent=`${e}% personal brand / ${100-e}% promotion`}$e.addEventListener("pointerdown",()=>{tr=!0});$e.addEventListener("input",()=>{tr=!0,Yl(Number($e.value))});$e.addEventListener("change",async()=>{const e=Number($e.value);$e.disabled=!0;try{const n=await K("engagement_mode",{action:"split",split:e});n&&typeof n.personal_brand_share=="number"&&ge({personal_brand_share:n.personal_brand_share}),await ve()}catch(n){A("Couldn’t set the lane split: "+((n==null?void 0:n.message)||n)),await ve()}finally{$e.disabled=!1,tr=!1}});let Xl=!1;function _g(e){if(!e||e.error)return;const n=e.mode||"medium";Re.value=n;const r=Array.isArray(e.rates)?e.rates:[],o={high:"Aggressive",medium:"Steady",low:"Chill"};for(const t of r){const i=Re.querySelector(`option[value="${t.mode}"]`);if(i&&t.est_posts_per_day!==null&&t.est_posts_per_day!==void 0){const a=Number(t.est_posts_per_day),s=o[t.mode]||t.mode;i.textContent=`${s} (~${a>=10?Math.round(a):a}/day)`}}sg.textContent="How many drafts per day the cycle produces."}async function _r(){try{const e=await K("posting_volume",{action:"get"});Xl=!0,_g(e)}catch{}}Re.addEventListener("change",async()=>{const e=Re.value;Re.disabled=!0;try{const n=await K("posting_volume",{action:"set",mode:e});n&&n.error?A("Couldn’t change posting volume: "+n.error):A(`Posting volume set to ${{high:"Aggressive",medium:"Steady",low:"Chill"}[e]||e}. Applies from the next cycle.`),await _r()}catch(n){A("Couldn’t change posting volume: "+((n==null?void 0:n.message)||n)),await _r()}finally{Re.disabled=!1}});Ne.addEventListener("click",()=>{ke=!ke,gr()});rr.addEventListener("click",()=>{Qe=!Qe,Dt.hidden=!Qe,rr.setAttribute("aria-expanded",String(Qe)),rr.classList.toggle("expanded",Qe)});Fl.addEventListener("click",e=>{const n=e.target;n&&n.id==="btn-update"&&bg()});async function bg(){if(sr)return;sr=!0;const e=document.getElementById("btn-update");e&&(e.disabled=!0,e.textContent="Updating…"),A("Installing the latest release… this can take a minute.");try{const n=await K("runtime",{action:"update"});n.ok?(A(`Updated to ${n.latest_published||"the latest version"}. ${n.takes_effect||"Restart the client to apply."}`),e&&(e.textContent="Update installed — restart to apply")):(A("Update failed (exit "+(n.exit_code??"?")+"). Try `npx social-autoposter@latest update` in a terminal."),e&&(e.disabled=!1,e.textContent="Retry update"))}catch(n){A("Update failed: "+((n==null?void 0:n.message)||n)),e&&(e.disabled=!1,e.textContent="Retry update")}finally{sr=!1}}be.addEventListener("click",async()=>{Ae.hidden=!0,be.disabled=!0,be.textContent="Starting…",A("Installing the runtime — this is a one-time download (~150MB+).");try{const e=await K("runtime",{action:"install"});if(e.runtime_ready){ge({runtime_ready:!0}),ve();return}Co(e.progress??null),Ro()}catch(e){be.disabled=!1,be.textContent="Retry install",Ae.textContent="Couldn't start install: "+((e==null?void 0:e.message)||e),Ae.hidden=!1}});da.addEventListener("click",()=>Ot(da,"Restarting…",async()=>{A("Restarting the S4L menu bar…");try{const e=await K("restart_menubar");typeof e.menubar_running=="boolean"&&ge({menubar_running:e.menubar_running}),A(e.menubar_running?"Menu bar restarted.":"Couldn’t confirm the menu bar came back"+(e.detail?": "+e.detail:"."))}catch(e){A("Couldn’t restart the menu bar: "+((e==null?void 0:e.message)||e))}}));const yg=["website","description","icp","voice","differentiator","search_topics","get_started_link","content_guardrails"],$g=["description","voice","search_topics","content_angle","content_guardrails"],br={website:"Website",description:"What it does",icp:"Target audience",voice:"Voice",differentiator:"Differentiator",search_topics:"Search topics",get_started_link:"Get-started link",content_guardrails:"Content guardrails",content_angle:"Content angle"},pa=new Set(["website","description","icp","voice","differentiator","search_topics","get_started_link","content_guardrails"]),kg=new Set(["website","get_started_link"]);function et(e){const n=e.replace(/[_-]+/g," ").trim();return n?n.charAt(0).toUpperCase()+n.slice(1):e}function ed(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function fa(e){return Array.isArray(e)&&e.every(n=>typeof n=="string")?{kind:"list",text:e.join(`
74
74
  `)}:e==null||typeof e=="string"?{kind:"text",text:String(e??"")}:{kind:"json",text:JSON.stringify(e,null,2)}}function ha(e,n,r){const o=document.createElement("div");o.className="settings-field";const t=document.createElement("label");t.textContent=e+(n.kind==="list"?" (one per line)":""),o.appendChild(t);let i;if(n.kind==="text"&&r)i=document.createElement("input"),i.type="text",i.className="settings-input";else{const a=document.createElement("textarea"),s=n.text?n.text.split(`
75
75
  `).reduce((c,p)=>c+Math.max(1,Math.ceil(p.length/60)),0):1;a.rows=Math.min(10,Math.max(2,s)),a.className="settings-textarea"+(n.kind==="json"?" mono":""),i=a}return i.value=n.text,n.text||(i.placeholder="Not set"),i.dataset.orig=n.text,o.appendChild(i),{wrap:o,el:i}}function Sg(e){const n=document.createElement("div");n.className="settings-project";const r=document.createElement("div");r.className="settings-project-head";const o=document.createElement("span");if(o.className="settings-project-name",o.textContent=e.name,r.appendChild(o),e.persona){const f=document.createElement("span");f.className="settings-project-tag",f.textContent="personal brand",r.appendChild(f)}const t=document.createElement("span");t.className="settings-project-state",t.textContent=e.ready?"ready":"missing: "+e.missing_required.join(", "),r.appendChild(t),n.appendChild(r);const i=[],a=e.persona?$g:yg;for(const f of a){const _=e.fields[f];if(ed(_)&&Object.keys(_).length){const z=document.createElement("fieldset");z.className="settings-group";const S=document.createElement("legend");S.textContent=br[f]||et(f),z.appendChild(S);for(const[g,$]of Object.entries(_)){const y=fa($),I=ha(et(g),y,!1);z.appendChild(I.wrap),i.push({key:f,sub:g,kind:y.kind,el:I.el,orig:y.text})}n.appendChild(z);continue}const w=f==="search_topics"?{kind:"list",text:Array.isArray(_)?_.map(String).join(`
76
76
  `):String(_??"")}:fa(_),b=ha(br[f]||et(f),w,kg.has(f));n.appendChild(b.wrap),i.push({key:f,kind:w.kind,el:b.el,orig:w.text})}if(e.extra_keys.length){const f=document.createElement("div");f.className="settings-extra",f.textContent="Advanced (edit via chat): "+e.extra_keys.join(", "),n.appendChild(f)}const s=document.createElement("div");s.className="settings-actions";const c=document.createElement("button");c.className="primary",c.textContent="Save changes",c.disabled=!0;const p=document.createElement("span");p.className="settings-status",s.appendChild(c),s.appendChild(p),n.appendChild(s);const h=()=>{c.disabled=!i.some(f=>f.el.value!==f.orig)};for(const f of i)f.el.addEventListener("input",h);return c.addEventListener("click",()=>void wg(e,i,c,p)),n}async function wg(e,n,r,o){const t=n.filter(h=>h.el.value!==h.orig);if(!t.length)return;const i={name:e.name},a={},s=h=>(br[h.key]||et(h.key))+(h.sub?` · ${et(h.sub)}`:""),c=[...new Set(t.filter(h=>h.sub!==void 0).map(h=>h.key))];for(const h of c){const f=ed(e.fields[h])?{...e.fields[h]}:{};for(const _ of n.filter(w=>w.key===h&&w.sub!==void 0)){const w=_.el.value;if(_.kind==="list"){const b=w.split(`
@@ -198,18 +198,18 @@ container holding the app. Specify either width or maxWidth, and either height o
198
198
  <div class="lane-note" id="mode-sub"></div>
199
199
  </section>
200
200
 
201
- <!-- Scanner: the cycle that drives the whole pipeline. A live status /
202
- countdown line + a one-click Run-now, mirroring the menu-bar dropdown.
203
- Both read scripts/live_status.py so the surfaces can't drift. Shown
204
- post-setup only. -->
205
- <section class="section" id="scanner-card" hidden>
201
+ <!-- Drafting pipeline: the cycle the user triggers — it scans for threads
202
+ first, then hands off to the drafter. A live status / countdown line +
203
+ a one-click Run-now, mirroring the menu-bar dropdown. Both read
204
+ scripts/live_status.py so the surfaces can't drift. Shown post-setup. -->
205
+ <section class="section" id="drafting-card" hidden>
206
206
  <div class="section-row">
207
- <span class="section-label">Scanner</span>
207
+ <span class="section-label">Drafting</span>
208
208
  <div class="section-actions">
209
- <button id="btn-scan-now" class="ghost">Run scan now</button>
209
+ <button id="btn-run-drafting" class="ghost">Run drafting now</button>
210
210
  </div>
211
211
  </div>
212
- <div class="live-status" id="scanner-status"></div>
212
+ <div class="live-status" id="drafting-status"></div>
213
213
  </section>
214
214
 
215
215
  <section class="section" id="live-card" hidden>
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.6-rc.30",
3
- "installedAt": "2026-07-18T00:45:01.263Z"
2
+ "version": "1.7.6-rc.32",
3
+ "installedAt": "2026-07-18T01:56:01.177Z"
4
4
  }
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.6-rc.30",
5
+ "version": "1.7.6-rc.32",
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": {
@@ -49,12 +49,12 @@
49
49
  "description": "Restart the S4L menu bar app"
50
50
  },
51
51
  {
52
- "name": "scan_now",
53
- "description": "Run the scanner now"
52
+ "name": "run_drafting",
53
+ "description": "Run the drafting pipeline now"
54
54
  },
55
55
  {
56
- "name": "scan_status",
57
- "description": "Live scanner status + next-run countdown"
56
+ "name": "drafting_status",
57
+ "description": "Live drafting-pipeline status + next-run countdown"
58
58
  },
59
59
  {
60
60
  "name": "posting_volume",
@@ -291,10 +291,10 @@ STOP_FLAG = os.path.join(st.state_dir(), "stopped.flag")
291
291
  # pauseFlagPath() in mcp/src/index.ts.
292
292
  PAUSE_FLAG = os.path.join(st.state_dir(), "paused.flag")
293
293
 
294
- # The launchd kicker label for the scanner (the twitter scan+draft cycle). "Run
295
- # scan now" kickstarts it directly — Claude-independent, so it works even when
296
- # the MCP server / Desktop is closed. Keep in sync with TWITTER_AUTOPILOT_LABEL
297
- # in mcp/src/index.ts.
294
+ # The launchd kicker label for the drafting pipeline (the twitter cycle: scan,
295
+ # then draft). "Run drafting now" kickstarts it directly — Claude-independent, so
296
+ # it works even when the MCP server / Desktop is closed. Keep in sync with
297
+ # TWITTER_AUTOPILOT_LABEL in mcp/src/index.ts.
298
298
  TWITTER_CYCLE_LABEL = "com.m13v.social-twitter-cycle"
299
299
 
300
300
  # Autopilot scheduled tasks. Queue workers must RUN in a dedicated folder
@@ -2911,12 +2911,12 @@ class S4LMenuBar(rumps.App):
2911
2911
  self._channel = snap.get("channel") or "stable"
2912
2912
  self._latest_tag = snap.get("latest_tag")
2913
2913
 
2914
- # Live pipeline status + next-scanner-run countdown — ONE shared module
2914
+ # Live drafting-pipeline status + next-run countdown — ONE shared module
2915
2915
  # (scripts/live_status.py) the dashboard widget reads via snapshot.py too,
2916
2916
  # so the tray and the widget can't drift. Cheap (two small file reads),
2917
2917
  # computed fresh here rather than off the 4s-cached snap so the countdown
2918
2918
  # stays honest. Best-effort: any failure leaves an empty dict and the menu
2919
- # simply omits the scanner status/countdown lines.
2919
+ # simply omits the drafting status/countdown lines.
2920
2920
  try:
2921
2921
  import live_status as _live_status
2922
2922
 
@@ -2975,7 +2975,7 @@ class S4LMenuBar(rumps.App):
2975
2975
  # View mode: rebuilds when the review layout changes (checkmark
2976
2976
  # in the "View Mode" submenu + whether the cadence item shows).
2977
2977
  st.read_review_layout(),
2978
- # Scanner status + countdown (State C): rebuild when the live
2978
+ # Drafting status + countdown (State C): rebuild when the live
2979
2979
  # pipeline verb changes (idle <-> scanning/drafting/posting) and
2980
2980
  # when the next-run countdown crosses a 15s bucket — bucketed so an
2981
2981
  # open menu isn't torn down every second for a -1s tick.
@@ -4099,33 +4099,38 @@ class S4LMenuBar(rumps.App):
4099
4099
  items += self._state_b(ob, blocker)
4100
4100
  else:
4101
4101
  items += self._state_c(snap, pending_count)
4102
- # Scanner controls (post-setup, every State-C menu regardless of
4103
- # pending drafts): a live status/countdown line + a one-click "Run
4104
- # scan now". The scanner is the cycle that drives the whole
4105
- # pipeline; these mirror the same controls on the dashboard widget,
4106
- # both reading scripts/live_status.py so the surfaces can't drift.
4102
+ # Drafting-pipeline control (post-setup, every State-C menu regardless
4103
+ # of pending drafts): ONE line — the action is the phrase, the live
4104
+ # state is in brackets. The pipeline scans first, then hands off to the
4105
+ # drafter; this mirrors the dashboard widget, both reading
4106
+ # scripts/live_status.py so the surfaces can't drift.
4107
+ # idle -> "Run drafting now (next run in 42s)" [clickable]
4108
+ # running -> "Run drafting now (drafting…)" [greyed]
4109
+ # paused -> "Run drafting now (paused)" [greyed]
4110
+ # Clickable only when idle & not paused: while a run is in flight
4111
+ # launchd would just no-op the single instance, and while paused the
4112
+ # kicker is unloaded so nothing would run.
4107
4113
  live = getattr(self, "_live", {}) or {}
4108
4114
  act_state = live.get("activity_state", "idle")
4109
- items.append(rumps.separator)
4110
- if act_state in ("scanning", "drafting", "posting"):
4111
- _verb = {"scanning": "Scanning", "drafting": "Drafting", "posting": "Posting"}[act_state]
4112
- items.append(self._label(f"⟳ {_verb} now…"))
4115
+ running = act_state in ("scanning", "drafting", "posting")
4116
+ paused = os.path.exists(PAUSE_FLAG)
4117
+ if paused:
4118
+ _bracket = "paused"
4119
+ elif running:
4120
+ _bracket = f"{act_state}…"
4113
4121
  else:
4114
4122
  _secs = live.get("next_run_secs")
4115
- if isinstance(_secs, int):
4116
- items.append(self._label(f"Next scan in {self._fmt_countdown(_secs)}"))
4117
- else:
4118
- items.append(self._label("Next scan within a minute"))
4119
- # Run now is disabled (shown as a plain label) while paused — the
4120
- # kicker is unloaded, so nothing would run — or while a scan is
4121
- # already in flight, where launchd would just no-op the single
4122
- # instance.
4123
- if os.path.exists(PAUSE_FLAG):
4124
- items.append(self._label("Run scan now (paused)"))
4125
- elif act_state == "scanning":
4126
- items.append(self._label("Run scan now (already scanning)"))
4123
+ _bracket = (
4124
+ f"next run in {self._fmt_countdown(_secs)}"
4125
+ if isinstance(_secs, int)
4126
+ else "next run within a minute"
4127
+ )
4128
+ _text = f"Run drafting now ({_bracket})"
4129
+ items.append(rumps.separator)
4130
+ if paused or running:
4131
+ items.append(self._label(_text)) # greyed: can't run right now
4127
4132
  else:
4128
- items.append(rumps.MenuItem("Run scan now", callback=self._scan_now))
4133
+ items.append(rumps.MenuItem(_text, callback=self._run_drafting))
4129
4134
 
4130
4135
  # Engagement lanes — ALWAYS visible (every state), not just post-setup, so
4131
4136
  # the user can see + flip either lane any time. Two INDEPENDENT checkmarks
@@ -4276,14 +4281,15 @@ class S4LMenuBar(rumps.App):
4276
4281
  return f"{s}s"
4277
4282
  return f"{s // 60}:{s % 60:02d}"
4278
4283
 
4279
- def _scan_now(self, _=None):
4280
- """Fire ONE scanner cycle immediately by kickstarting the launchd kicker
4281
- (com.m13v.social-twitter-cycle). Same mechanism the MCP's scan_now tool
4282
- and the first-run onboarding kick use — the job runs THROUGH launchd with
4283
- its full baked env + singleton lock, so it is NOT a bare hand-run of the
4284
- cycle. Done directly here (not via the loopback tool) so it works even
4285
- when Claude Desktop / the MCP server is closed. Best-effort + off the UI
4286
- thread so a slow launchctl never freezes the tray."""
4284
+ def _run_drafting(self, _=None):
4285
+ """Run the drafting pipeline ONE time immediately by kickstarting the
4286
+ launchd kicker (com.m13v.social-twitter-cycle). Same mechanism the MCP's
4287
+ run_drafting tool and the first-run onboarding kick use — the job runs
4288
+ THROUGH launchd with its full baked env + singleton lock, so it is NOT a
4289
+ bare hand-run of the cycle. Done directly here (not via the loopback tool)
4290
+ so it works even when Claude Desktop / the MCP server is closed.
4291
+ Best-effort + off the UI thread so a slow launchctl never freezes the
4292
+ tray."""
4287
4293
  def work():
4288
4294
  try:
4289
4295
  r = subprocess.run(
@@ -4292,11 +4298,11 @@ class S4LMenuBar(rumps.App):
4292
4298
  timeout=15,
4293
4299
  )
4294
4300
  if r.returncode == 0:
4295
- self._notify("S4L", "Scanning now — new drafts land in a few minutes.")
4301
+ self._notify("S4L", "Drafting started — new drafts land in a few minutes.")
4296
4302
  else:
4297
- self._notify("S4L", "Couldn’t start the scanner. Try again in a moment.")
4303
+ self._notify("S4L", "Couldn’t start drafting. Try again in a moment.")
4298
4304
  except Exception:
4299
- self._notify("S4L", "Couldn’t start the scanner. Try again in a moment.")
4305
+ self._notify("S4L", "Couldn’t start drafting. Try again in a moment.")
4300
4306
  # Repaint promptly so the status line flips to "Scanning now…" without
4301
4307
  # waiting for the next natural rebuild.
4302
4308
  self._sig = None
@@ -1282,11 +1282,16 @@ def write_review_layout(layout):
1282
1282
  # Server-side per-install throttle for the twitter cycle's virality bar:
1283
1283
  # high|medium|low|None (None = driver default). Source of truth is
1284
1284
  # installations.posting_mode via /api/v1/installations/posting-mode; unlike
1285
- # the lane flags there is NO local mode.json copy, because the whole point is
1286
- # remote adjustability (dashboard and menubar must agree with the server).
1285
+ # the lane flags there is NO local mode.json copy that RESOLVES the mode,
1286
+ # because the whole point is remote adjustability (dashboard and menubar must
1287
+ # agree with the server).
1287
1288
  # Reads are cached so the menu rebuild tick never blocks on the network: a
1288
1289
  # stale/missing cache kicks one background refresh and returns the last-known
1289
- # value (None until the first refresh lands; the next rebuild shows the mark).
1290
+ # value. The in-memory cache is seeded from a disk copy of the last server
1291
+ # answer (scripts/s4l_posting_mode.py cache file, 2026-07-17): without it,
1292
+ # every menubar restart (updates restart the menubar) displayed the default
1293
+ # "Steady" until the first refresh landed, which read as the user's chosen
1294
+ # mode having been reset. The seed never suppresses a refresh (at stays 0.0).
1290
1295
  POSTING_MODE_TTL_SECS = 600
1291
1296
  _posting_mode_cache = {"mode": None, "known": False, "at": 0.0}
1292
1297
  _posting_mode_lock = threading.Lock()
@@ -1299,20 +1304,38 @@ def _scripts_on_path():
1299
1304
  sys.path.insert(0, scripts)
1300
1305
 
1301
1306
 
1307
+ def _seed_posting_mode_from_disk():
1308
+ """Seed the in-memory cache with the last server answer that any surface
1309
+ persisted (menubar, MCP tool, panel). at stays 0.0 so the first read still
1310
+ kicks a real refresh; the server answer overwrites the seed when it lands."""
1311
+ try:
1312
+ _scripts_on_path()
1313
+ from s4l_posting_mode import read_cached_mode
1314
+
1315
+ mode = read_cached_mode()
1316
+ if mode:
1317
+ with _posting_mode_lock:
1318
+ _posting_mode_cache["mode"] = mode
1319
+ except Exception:
1320
+ pass
1321
+
1322
+
1323
+ _seed_posting_mode_from_disk()
1324
+
1325
+
1302
1326
  def _refresh_posting_mode():
1303
1327
  try:
1304
1328
  _scripts_on_path()
1305
1329
  from http_api import api_get
1330
+ from s4l_posting_mode import write_cached_mode
1306
1331
 
1307
1332
  r = api_get("/api/v1/installations/posting-mode")
1308
1333
  d = (r or {}).get("data") or {}
1309
1334
  mode = d.get("mode")
1335
+ mode = mode if mode in ("high", "medium", "low") else None
1336
+ write_cached_mode(mode)
1310
1337
  with _posting_mode_lock:
1311
- _posting_mode_cache.update(
1312
- mode=mode if mode in ("high", "medium", "low") else None,
1313
- known=True,
1314
- at=time.time(),
1315
- )
1338
+ _posting_mode_cache.update(mode=mode, known=True, at=time.time())
1316
1339
  except Exception as e:
1317
1340
  sys.stderr.write(f"[s4l-state] posting-mode refresh failed: {e}\n")
1318
1341
  with _posting_mode_lock:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.6-rc.30",
3
+ "version": "1.7.6-rc.32",
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.6-rc.30",
3
+ "version": "1.7.6-rc.32",
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",
@@ -6,13 +6,21 @@ tool and the panel. The mode (high|medium|low|None) is stored SERVER-SIDE on
6
6
  installations.posting_mode; the virality-threshold route maps it to a
7
7
  percentile and overrides the cycle driver's request, so changes apply on the
8
8
  install's next cycle with no client update. There is deliberately NO local
9
- mode.json copy (dashboard, menubar, and panel must always agree with the
10
- server).
9
+ mode.json copy that RESOLVES the mode (dashboard, menubar, and panel must
10
+ always agree with the server).
11
+
12
+ Display cache (2026-07-17): a last-known copy of the server's answer IS kept
13
+ at <state_dir>/posting-mode.cache.json, written on every successful get/set
14
+ and read only when the network is unavailable (and by the menubar to seed its
15
+ boot display). Without it, every menubar/panel restart showed the default
16
+ "Steady" until the first network round trip landed, which read as the user's
17
+ chosen mode having been reset. The cache never overrides a server answer.
11
18
 
12
19
  Usage:
13
20
  python3 scripts/s4l_posting_mode.py get
14
21
  -> {"mode": ..., "rates": [{mode,pctile,threshold,est_posts_per_day}...],
15
22
  "pool_count": N, "batch_count": N, "window_days": 7}
23
+ (on network failure with a cache present: {"mode": ..., "cached": true})
16
24
  python3 scripts/s4l_posting_mode.py set <high|medium|low|default>
17
25
  -> {"mode": ...} ("default" clears the override)
18
26
 
@@ -21,6 +29,7 @@ Prints JSON on stdout; exits non-zero with an {"error": ...} JSON on failure.
21
29
  import json
22
30
  import os
23
31
  import sys
32
+ import time
24
33
 
25
34
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
35
  from http_api import api_get, api_post # noqa: E402
@@ -28,13 +37,60 @@ from http_api import api_get, api_post # noqa: E402
28
37
  VALID = ("high", "medium", "low")
29
38
 
30
39
 
40
+ def _cache_path() -> str:
41
+ state_dir = os.environ.get("S4L_STATE_DIR") or os.path.join(
42
+ os.path.expanduser("~"), ".social-autoposter-mcp"
43
+ )
44
+ return os.path.join(state_dir, "posting-mode.cache.json")
45
+
46
+
47
+ def read_cached_mode():
48
+ """Last-known SERVER mode ('high'|'medium'|'low') or None (unset/no cache).
49
+ Display-only: the server stays the source of truth."""
50
+ try:
51
+ with open(_cache_path()) as fh:
52
+ d = json.load(fh)
53
+ m = d.get("mode")
54
+ return m if m in VALID else None
55
+ except Exception:
56
+ return None
57
+
58
+
59
+ def write_cached_mode(mode):
60
+ """Persist the last-known server mode (None = server says unset). Best
61
+ effort, never raises: a cache miss only costs the boot display."""
62
+ try:
63
+ path = _cache_path()
64
+ os.makedirs(os.path.dirname(path), exist_ok=True)
65
+ tmp = path + ".tmp"
66
+ with open(tmp, "w") as fh:
67
+ json.dump({"mode": mode if mode in VALID else None, "at": time.time()}, fh)
68
+ os.replace(tmp, path)
69
+ except Exception:
70
+ pass
71
+
72
+
31
73
  def main() -> int:
32
74
  argv = sys.argv[1:]
33
75
  cmd = argv[0] if argv else "get"
34
76
  try:
35
77
  if cmd == "get":
36
- r = api_get("/api/v1/installations/posting-mode")
37
- print(json.dumps((r or {}).get("data") or {}))
78
+ try:
79
+ r = api_get("/api/v1/installations/posting-mode")
80
+ except Exception as e:
81
+ # Offline / server unreachable: answer from the last-known
82
+ # cache so UIs keep showing the user's chosen mode instead of
83
+ # silently falling back to "Steady". No "error" key: consumers
84
+ # treat that as a hard failure and drop the payload.
85
+ cached = read_cached_mode()
86
+ if cached is not None:
87
+ print(json.dumps({"mode": cached, "cached": True}))
88
+ return 0
89
+ raise e
90
+ d = (r or {}).get("data") or {}
91
+ if "mode" in d:
92
+ write_cached_mode(d.get("mode"))
93
+ print(json.dumps(d))
38
94
  return 0
39
95
  if cmd == "set":
40
96
  if len(argv) < 2:
@@ -46,7 +102,10 @@ def main() -> int:
46
102
  print(json.dumps({"error": f"invalid mode {raw!r}"}))
47
103
  return 2
48
104
  r = api_post("/api/v1/installations/posting-mode", {"mode": mode})
49
- print(json.dumps((r or {}).get("data") or {}))
105
+ d = (r or {}).get("data") or {}
106
+ if "mode" in d:
107
+ write_cached_mode(d.get("mode"))
108
+ print(json.dumps(d))
50
109
  return 0
51
110
  print(json.dumps({"error": f"unknown command {cmd!r}"}))
52
111
  return 2