social-autoposter 1.6.68 → 1.6.70
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 +294 -309
- package/mcp/dist/panel.html +54 -111
- package/mcp/dist/runtime.js +175 -0
- package/mcp/dist/setup.js +55 -9
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +5 -17
- package/mcp/menubar/s4l_card.py +263 -0
- package/mcp/menubar/s4l_menubar.py +477 -0
- package/mcp/menubar/s4l_state.py +363 -0
- package/package.json +2 -1
- package/scripts/harness_overlay.py +97 -6
- package/scripts/reset-test-machine.sh +18 -1
- package/setup/SKILL.md +11 -11
- package/skill/run-twitter-cycle.sh +20 -2
package/mcp/dist/index.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// draft_cycle - scan + draft, return all drafts as a numbered table for the
|
|
6
6
|
// user to review in chat (posts nothing).
|
|
7
7
|
// post_drafts - post the drafts the user chose by number from a batch.
|
|
8
|
-
// autopilot - one tool, action = enable | disable | status (launchd job).
|
|
9
8
|
// get_stats - read-only post + engagement stats.
|
|
10
9
|
//
|
|
11
10
|
// THIN wrapper. The pipeline brain (scan, score, drafting prompts, posting)
|
|
@@ -18,9 +17,9 @@ import os from "node:os";
|
|
|
18
17
|
import path from "node:path";
|
|
19
18
|
import fs from "node:fs";
|
|
20
19
|
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
21
|
-
import { applySetup, resolveProject,
|
|
20
|
+
import { applySetup, resolveProject, listManagedProjectStatus, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
|
|
22
21
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
23
|
-
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, } from "./runtime.js";
|
|
22
|
+
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, } from "./runtime.js";
|
|
24
23
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
25
24
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
26
25
|
import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
|
|
@@ -34,7 +33,8 @@ const PANEL_URI = "ui://social-autoposter/panel.html";
|
|
|
34
33
|
const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
|
|
35
34
|
const TWITTER_AUTOPILOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${TWITTER_AUTOPILOT_LABEL}.plist`);
|
|
36
35
|
// Daily self-updater. Enabled alongside autopilot so a hands-free (headless)
|
|
37
|
-
// install keeps itself current — the interactive `
|
|
36
|
+
// install keeps itself current — the interactive `runtime` tool (action:'update')
|
|
37
|
+
// only helps when
|
|
38
38
|
// a human-facing agent session is open, which an autopilot box never has.
|
|
39
39
|
const UPDATER_LABEL = "com.m13v.social-autoposter-update";
|
|
40
40
|
const UPDATER_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${UPDATER_LABEL}.plist`);
|
|
@@ -171,15 +171,16 @@ const server = new McpServer({
|
|
|
171
171
|
"about it. A feature nobody hears about is wasted distribution. Use judgment, suggest it for " +
|
|
172
172
|
"genuinely shareable moments, not routine refactors, chores, or internal cleanup.\n\n" +
|
|
173
173
|
"STAY CURRENT. At the start of a session, and again right after you help the user ship/merge/" +
|
|
174
|
-
"release, call the `
|
|
175
|
-
"user and offer to run `
|
|
176
|
-
"`update_available` and an `update_hint`.\n\n" +
|
|
177
|
-
"TYPICAL FLOW: `
|
|
178
|
-
"user approves / edits / skips every draft in a single form) ->
|
|
179
|
-
"
|
|
180
|
-
"
|
|
174
|
+
"release, call the `runtime` tool (action:'version'). If `update_available` is true, tell the " +
|
|
175
|
+
"user and offer to run `runtime` with action:'update'. The `project_config` tool's status also " +
|
|
176
|
+
"surfaces `update_available` and an `update_hint`.\n\n" +
|
|
177
|
+
"TYPICAL FLOW: `project_config` (configure OR edit the project, and connect X) -> `draft_cycle` " +
|
|
178
|
+
"(scan + review a batch; the user approves / edits / skips every draft in a single form) -> " +
|
|
179
|
+
"`get_stats` (see performance). Run `project_config` first; the other tools refuse until a " +
|
|
180
|
+
"project is fully configured. To change anything about a project later, call `project_config` " +
|
|
181
|
+
"again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
|
|
181
182
|
"RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
|
|
182
|
-
"(`draft_cycle`, `post_drafts`, `
|
|
183
|
+
"(`draft_cycle`, `post_drafts`, `get_stats`), end your turn by " +
|
|
183
184
|
"calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
|
|
184
185
|
"`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
|
|
185
186
|
});
|
|
@@ -238,9 +239,10 @@ function blockedReasonMessage(reason) {
|
|
|
238
239
|
"Wait for the limit to reset, then run draft_cycle again.");
|
|
239
240
|
case "no_search_topics":
|
|
240
241
|
return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
|
|
241
|
-
"DB (project_search_topics) and are seeded from your project's `search_topics`
|
|
242
|
-
"Re-run the `
|
|
243
|
-
"phrases your buyers tweet about);
|
|
242
|
+
"DB (project_search_topics) and are seeded from your project's `search_topics` when you " +
|
|
243
|
+
"configure it. Re-run the `project_config` tool for this project with a `search_topics` list " +
|
|
244
|
+
"(comma-separated keywords/phrases your buyers tweet about); it seeds them automatically, then " +
|
|
245
|
+
"run draft_cycle again.");
|
|
244
246
|
case "topics_api_unreachable":
|
|
245
247
|
return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
|
|
246
248
|
"before scanning. This is usually a transient backend/network issue. Try draft_cycle again in a " +
|
|
@@ -498,6 +500,19 @@ async function postApproved(batchId, plan) {
|
|
|
498
500
|
catch {
|
|
499
501
|
/* keep raw */
|
|
500
502
|
}
|
|
503
|
+
// On a successful run, mark the posted candidates in the ORIGINAL plan so the
|
|
504
|
+
// other review surface (chat vs menu-bar pop-ups) skips them — cross-surface
|
|
505
|
+
// de-dup. Best-effort; the pipeline's own already-posted check is the backstop.
|
|
506
|
+
if (res.code === 0) {
|
|
507
|
+
for (const c of approved)
|
|
508
|
+
c.posted = true;
|
|
509
|
+
try {
|
|
510
|
+
writePlan(batchId, plan);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
/* best effort */
|
|
514
|
+
}
|
|
515
|
+
}
|
|
501
516
|
return {
|
|
502
517
|
attempted: approved.length,
|
|
503
518
|
exit_code: res.code,
|
|
@@ -509,10 +524,10 @@ async function postApproved(batchId, plan) {
|
|
|
509
524
|
// This is NOT a tool — the model never auto-calls it. It surfaces in clients
|
|
510
525
|
// that render prompts as slash-commands / starters (e.g. Claude Desktop's "/"
|
|
511
526
|
// menu). When the user picks it, it injects the message below into the chat,
|
|
512
|
-
// which nudges the agent to start the real onboarding via the `
|
|
527
|
+
// which nudges the agent to start the real onboarding via the `project_config` tool.
|
|
513
528
|
// Deliberately a DUMB POINTER: it names no fields and no steps, so it can never
|
|
514
|
-
// drift from REQUIRED_FIELDS / the
|
|
515
|
-
// `
|
|
529
|
+
// drift from REQUIRED_FIELDS / the project_config tool's flow. All real logic stays
|
|
530
|
+
// in `project_config`; this is just a convenience handle to begin.
|
|
516
531
|
server.registerPrompt("getting-started", {
|
|
517
532
|
title: "Set up social-autoposter",
|
|
518
533
|
description: "Start here. Walks you through configuring a product and connecting your X/Twitter " +
|
|
@@ -561,29 +576,36 @@ const WEBSITE_RESEARCH_INSTRUCTIONS = "PRODUCT RESEARCH (do this before saving t
|
|
|
561
576
|
"register, vibe) while keeping every product CLAIM factual to the site. Don't invent " +
|
|
562
577
|
"features, metrics, or guarantees the site doesn't state.\n" +
|
|
563
578
|
"5. Save the best conservative factual draft without adding a confirmation round-trip. Call " +
|
|
564
|
-
"
|
|
579
|
+
"project_config with name + the product fields (plus voice/search_topics from the profile scan). If the " +
|
|
565
580
|
"site is thin or unreachable, use only supported facts and leave optional detail conservative; " +
|
|
566
581
|
"ask the user only if a required field is genuinely unknowable.";
|
|
567
|
-
// ----
|
|
582
|
+
// ---- project_config: per-project config (the "brain": project, website, voice) -----
|
|
568
583
|
// Run this FIRST. The action tools refuse until at least one project is ready.
|
|
569
584
|
// You can set up MULTIPLE products and fill each project's fields INCREMENTALLY
|
|
570
585
|
// across several calls — readiness is derived from config.json, never a stored
|
|
571
586
|
// flag. Call with status:true (or just no name) to list every project this
|
|
572
587
|
// install manages and what each still needs.
|
|
573
|
-
tool("
|
|
574
|
-
title: "
|
|
575
|
-
description: "
|
|
576
|
-
"
|
|
577
|
-
"
|
|
578
|
-
"project
|
|
588
|
+
tool("project_config", {
|
|
589
|
+
title: "Configure or edit a project",
|
|
590
|
+
description: "The ONE tool for a project's whole lifecycle: create it, EDIT it later, and connect its X " +
|
|
591
|
+
"account. There is no separate raw-config editor — every project change goes through here so " +
|
|
592
|
+
"it validates, merges, and re-seeds the search-topic universe the cycle reads. To CHANGE an " +
|
|
593
|
+
"existing project (its website, voice, icp, differentiator, search_topics, guardrails, CTA " +
|
|
594
|
+
"link), call this with that project's `name` and ONLY the fields you want to change; it merges " +
|
|
595
|
+
"onto what's already saved and never clobbers untouched fields. Run it FIRST before any " +
|
|
596
|
+
"drafting or autopilot. A user's request to set up social-autoposter is a request to finish " +
|
|
597
|
+
"the workflow end to end, not to interview them step by step: resume from current status, " +
|
|
598
|
+
"infer discoverable fields, and keep taking safe actions until runtime, project, X connection, " +
|
|
599
|
+
"topic seeding, and draft-only verification are complete.\n" +
|
|
579
600
|
"Two jobs:\n" +
|
|
580
|
-
"1) Configure a project this install posts for: its website, what it does
|
|
581
|
-
"to target (icp), and brand voice. To fill the PRODUCT fields, discover the
|
|
582
|
-
"config, conversation context, the connected X profile, or public research,
|
|
583
|
-
"with your own browser/fetch tools — read 5+ pages (home, pricing, features,
|
|
584
|
-
"blog, FAQ) to learn it deeply, rather than guessing from the name. Set up MULTIPLE
|
|
585
|
-
"(call once per product, identified by name); fill a project's fields
|
|
586
|
-
"several calls — pass whatever you have, it merges and tells you what's
|
|
601
|
+
"1) Configure (or edit) a project this install posts for: its website, what it does " +
|
|
602
|
+
"(description), who to target (icp), and brand voice. To fill the PRODUCT fields, discover the " +
|
|
603
|
+
"product URL from config, conversation context, the connected X profile, or public research, " +
|
|
604
|
+
"then visit it with your own browser/fetch tools — read 5+ pages (home, pricing, features, " +
|
|
605
|
+
"about, docs/blog, FAQ) to learn it deeply, rather than guessing from the name. Set up MULTIPLE " +
|
|
606
|
+
"products (call once per product, identified by name); fill or edit a project's fields " +
|
|
607
|
+
"INCREMENTALLY across several calls — pass whatever you have, it merges and tells you what's " +
|
|
608
|
+
"still missing.\n" +
|
|
587
609
|
"2) Connect X/Twitter (action:'connect_x'): the autoposter posts through its OWN managed Chrome, " +
|
|
588
610
|
"which needs your logged-in x.com session. This imports x.com/twitter.com cookies from your " +
|
|
589
611
|
"everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into that browser — nothing else is " +
|
|
@@ -593,7 +615,7 @@ tool("setup", {
|
|
|
593
615
|
"Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
|
|
594
616
|
"whether X is connected. Use config, conversation context, profile_scan, and website research " +
|
|
595
617
|
"before asking for fields. Ask only if no product can be identified or an interactive login is " +
|
|
596
|
-
"unavoidable. The draft_cycle
|
|
618
|
+
"unavoidable. The draft_cycle and get_stats tools refuse to run until a project is " +
|
|
597
619
|
"fully set up.",
|
|
598
620
|
inputSchema: {
|
|
599
621
|
status: z.boolean().optional(),
|
|
@@ -655,6 +677,17 @@ tool("setup", {
|
|
|
655
677
|
.string()
|
|
656
678
|
.optional()
|
|
657
679
|
.describe("Anything the posts must avoid saying / claiming"),
|
|
680
|
+
fields: z
|
|
681
|
+
.record(z.string(), z.any())
|
|
682
|
+
.optional()
|
|
683
|
+
.describe("Escape hatch to edit ANY other project field the named props above don't cover — e.g. " +
|
|
684
|
+
"weight, platform, voice_relationship, booking_link, qualification, subreddit_bans, " +
|
|
685
|
+
"short_links_host, short_links_live, content_angle, messaging, landing_pages, posthog. " +
|
|
686
|
+
"Pass {name:'<project>', fields:{<key>:<value>, ...}}; each key SHALLOW-merges onto the " +
|
|
687
|
+
"project, REPLACING that key's whole value (read the current value via status:true first if " +
|
|
688
|
+
"you only want to tweak part of a nested object, then pass the full new value). A value of " +
|
|
689
|
+
"null DELETES the key. 'name' is ignored here (can't rename through this path). This is how " +
|
|
690
|
+
"you edit advanced config without any raw whole-file overwrite."),
|
|
658
691
|
},
|
|
659
692
|
}, async (args) => {
|
|
660
693
|
// ---- List import sources (for the panel dropdown) ---------------------
|
|
@@ -707,7 +740,7 @@ tool("setup", {
|
|
|
707
740
|
"Allow (or Always Allow). If you use more than one browser you may see it a couple of times, " +
|
|
708
741
|
"once per browser.",
|
|
709
742
|
how_to_proceed: "If the user explicitly requested setup or connection, relay the say_to_user line as a brief " +
|
|
710
|
-
"heads-up and immediately call
|
|
743
|
+
"heads-up and immediately call project_config again with action:'connect_x', confirm:true; do not wait " +
|
|
711
744
|
"for another yes/no reply. Optionally pass the recommended x_source. If the user only asked " +
|
|
712
745
|
"what connection would do, stop after this preview.",
|
|
713
746
|
});
|
|
@@ -744,12 +777,12 @@ tool("setup", {
|
|
|
744
777
|
: undefined,
|
|
745
778
|
onboarding: onboardingSnapshot(),
|
|
746
779
|
next_step: r.connected
|
|
747
|
-
? "X is connected. Next, run
|
|
780
|
+
? "X is connected. Next, run project_config action:'profile_scan' to read this account's bio + recent " +
|
|
748
781
|
"posts + replies and draft the project's voice/icp/search_topics in the user's own register " +
|
|
749
782
|
"before saving. Then run draft_cycle once the project is fully set up."
|
|
750
783
|
: r.state === "needs_login"
|
|
751
784
|
? "The user must finish signing in to x.com in the Chrome window that just opened. Tell " +
|
|
752
|
-
"them that single required action, then call
|
|
785
|
+
"them that single required action, then call project_config action:'connect_x', confirm:true again."
|
|
753
786
|
: "X is not connected yet. " + summarizeXAuth(r),
|
|
754
787
|
});
|
|
755
788
|
}
|
|
@@ -758,14 +791,14 @@ tool("setup", {
|
|
|
758
791
|
// successful connect_x) to read the user's bio + recent posts + recent
|
|
759
792
|
// replies. Returns the raw corpus plus grounding_instructions; synthesis of
|
|
760
793
|
// voice/icp/topics happens IN THIS CONVERSATION (no nested model), then the
|
|
761
|
-
// agent confirms with the user and calls
|
|
794
|
+
// agent confirms with the user and calls project_config to persist. Read-only.
|
|
762
795
|
if (args.action === "profile_scan") {
|
|
763
796
|
// Handle is auto-detected from the live logged-in session by the scanner.
|
|
764
797
|
recordOnboardingAttempt("profile_scanned");
|
|
765
798
|
const scan = await xScanProfile();
|
|
766
799
|
if (!scan.ok) {
|
|
767
800
|
const hint = scan.state === "browser_not_running" || scan.state === "no_handle"
|
|
768
|
-
? " Run
|
|
801
|
+
? " Run project_config action:'connect_x' (confirm:true) first so the account is connected, then retry profile_scan."
|
|
769
802
|
: "";
|
|
770
803
|
blockOnboardingMilestone("profile_scanned", `profile_${scan.state || "failed"}`, scan.error || "profile scan failed", { state: scan.state || "failed" });
|
|
771
804
|
return jsonContent({
|
|
@@ -838,7 +871,7 @@ tool("setup", {
|
|
|
838
871
|
update_available: ver.update_available,
|
|
839
872
|
update_hint: ver.update_available
|
|
840
873
|
? `A newer version (${ver.latest}) is available — you're on ${ver.installed}. ` +
|
|
841
|
-
`Tell the user and offer to run the \`
|
|
874
|
+
`Tell the user and offer to run the \`runtime\` tool with action:'update' ` +
|
|
842
875
|
`(or \`npx social-autoposter@latest update\`).`
|
|
843
876
|
: undefined,
|
|
844
877
|
required_fields: REQUIRED_FIELDS,
|
|
@@ -847,16 +880,16 @@ tool("setup", {
|
|
|
847
880
|
ready_for_verification: rtReady && configured && x.connected,
|
|
848
881
|
onboarding: onboardingSnapshot(),
|
|
849
882
|
next_step: !rtReady
|
|
850
|
-
? "Runtime is not ready. Call
|
|
883
|
+
? "Runtime is not ready. Call runtime action:'install', poll runtime action:'status' to completion, then continue setup automatically."
|
|
851
884
|
: projects.length === 0
|
|
852
|
-
? "No projects yet. Discover the product from conversation context and the connected X profile; research its website, infer a conservative complete project, and call
|
|
885
|
+
? "No projects yet. Discover the product from conversation context and the connected X profile; research its website, infer a conservative complete project, and call project_config. Ask only if no product can be identified." +
|
|
853
886
|
(x.connected ? "" : " X is not connected yet either — detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn.")
|
|
854
887
|
: projects.every((p) => p.ready)
|
|
855
888
|
? (x.connected
|
|
856
889
|
? "All configured projects are ready and X is connected. Run draft_cycle now to verify end to end without posting. After it verifies, call the `dashboard` tool so the user sees the finished setup."
|
|
857
890
|
: "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
|
|
858
|
-
"x.com session. Detect sources and run
|
|
859
|
-
: "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call
|
|
891
|
+
"x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
|
|
892
|
+
: "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call project_config again. Ask only if a required field is genuinely unknowable." +
|
|
860
893
|
(x.connected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
|
|
861
894
|
});
|
|
862
895
|
}
|
|
@@ -938,6 +971,17 @@ tool("setup", {
|
|
|
938
971
|
}
|
|
939
972
|
}
|
|
940
973
|
}
|
|
974
|
+
// Surface any advanced (escape-hatch) field edits in the note so the
|
|
975
|
+
// agent can confirm exactly what changed to the user.
|
|
976
|
+
let advancedNote = "";
|
|
977
|
+
if (result.fields_set.length || result.fields_removed.length) {
|
|
978
|
+
const parts = [];
|
|
979
|
+
if (result.fields_set.length)
|
|
980
|
+
parts.push(`set ${result.fields_set.join(", ")}`);
|
|
981
|
+
if (result.fields_removed.length)
|
|
982
|
+
parts.push(`removed ${result.fields_removed.join(", ")}`);
|
|
983
|
+
advancedNote = ` Advanced fields updated: ${parts.join("; ")}.`;
|
|
984
|
+
}
|
|
941
985
|
return jsonContent({
|
|
942
986
|
ok: true,
|
|
943
987
|
project: result.project,
|
|
@@ -947,16 +991,19 @@ tool("setup", {
|
|
|
947
991
|
topics_seeded: topicsSeeded,
|
|
948
992
|
topic_count: topicCount,
|
|
949
993
|
search_queries: searchQueries,
|
|
994
|
+
fields_set: result.fields_set,
|
|
995
|
+
fields_removed: result.fields_removed,
|
|
950
996
|
config_path: configPath(),
|
|
951
997
|
onboarding: onboardingSnapshot(),
|
|
952
|
-
note: result.ready
|
|
998
|
+
note: (result.ready
|
|
953
999
|
? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
|
|
954
|
-
`detect sources, warn about keychain prompts, and call
|
|
1000
|
+
`detect sources, warn about keychain prompts, and call project_config with ` +
|
|
955
1001
|
`action:'connect_x', confirm:true immediately. Once X is connected, run draft_cycle to ` +
|
|
956
1002
|
`verify without posting. Do not enable autopilot unless explicitly requested.`
|
|
957
1003
|
: `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
|
|
958
1004
|
`First derive those fields from existing context, profile_scan, and website research, then ` +
|
|
959
|
-
`call
|
|
1005
|
+
`call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
|
|
1006
|
+
advancedNote,
|
|
960
1007
|
});
|
|
961
1008
|
}
|
|
962
1009
|
catch (e) {
|
|
@@ -1035,6 +1082,15 @@ tool("draft_cycle", {
|
|
|
1035
1082
|
outcome: "review_batch",
|
|
1036
1083
|
draft_count: count,
|
|
1037
1084
|
});
|
|
1085
|
+
// Fire the menu-bar pop-up review (default). The chat-table review below is
|
|
1086
|
+
// unchanged; both surfaces can approve, de-duped by the plan `posted` flag.
|
|
1087
|
+
writeReviewRequest({
|
|
1088
|
+
batch_id: drafted.batchId,
|
|
1089
|
+
project: proj,
|
|
1090
|
+
count,
|
|
1091
|
+
plan_path: planPath(drafted.batchId),
|
|
1092
|
+
created_at: new Date().toISOString(),
|
|
1093
|
+
});
|
|
1038
1094
|
const table = renderDraftsTable(plan);
|
|
1039
1095
|
const message = `Drafted ${count} ${count === 1 ? "reply" : "replies"} for "${proj}" ` +
|
|
1040
1096
|
`(batch ${drafted.batchId}). NOTHING has been posted yet.\n\n` +
|
|
@@ -1139,6 +1195,18 @@ tool("post_drafts", {
|
|
|
1139
1195
|
else
|
|
1140
1196
|
warnings.push(`ignored #${n}: out of range (1-${total})`);
|
|
1141
1197
|
});
|
|
1198
|
+
// Cross-surface de-dup: chat and the menu-bar pop-ups can both approve, so
|
|
1199
|
+
// never re-post a candidate the other surface already posted.
|
|
1200
|
+
const alreadyPosted = [];
|
|
1201
|
+
for (const n of Array.from(approve)) {
|
|
1202
|
+
if (candidates[n - 1]?.posted === true) {
|
|
1203
|
+
approve.delete(n);
|
|
1204
|
+
alreadyPosted.push(n);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (alreadyPosted.length) {
|
|
1208
|
+
warnings.push(`already posted (skipped): ${alreadyPosted.sort((a, b) => a - b).join(", ")}`);
|
|
1209
|
+
}
|
|
1142
1210
|
candidates.forEach((c, i) => (c.approved = approve.has(i + 1)));
|
|
1143
1211
|
writePlan(batch_id, plan);
|
|
1144
1212
|
if (approve.size === 0) {
|
|
@@ -1163,92 +1231,16 @@ tool("post_drafts", {
|
|
|
1163
1231
|
warnings,
|
|
1164
1232
|
});
|
|
1165
1233
|
});
|
|
1166
|
-
// ---- autopilot:
|
|
1167
|
-
tool(
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
}, async ({ action }) => {
|
|
1177
|
-
if (action !== "status" && !hasReadyProject()) {
|
|
1178
|
-
return textContent("No project is fully set up yet, so autopilot has nothing to post. Run the `setup` tool " +
|
|
1179
|
-
"first. Note: autopilot runs the background cycle across all configured projects; it is " +
|
|
1180
|
-
"not scoped to one project.");
|
|
1181
|
-
}
|
|
1182
|
-
const uid = process.getuid ? process.getuid() : 0;
|
|
1183
|
-
const logDir = path.join(repoDir(), "skill", "logs");
|
|
1184
|
-
if (action === "status") {
|
|
1185
|
-
const res = await run("launchctl", ["list"], { timeoutMs: 10_000 });
|
|
1186
|
-
const lines = res.stdout.split("\n");
|
|
1187
|
-
const loaded = lines.some((l) => l.includes(TWITTER_AUTOPILOT_LABEL));
|
|
1188
|
-
const updaterLoaded = lines.some((l) => l.includes(UPDATER_LABEL));
|
|
1189
|
-
return jsonContent({
|
|
1190
|
-
label: TWITTER_AUTOPILOT_LABEL,
|
|
1191
|
-
loaded,
|
|
1192
|
-
auto_update_label: UPDATER_LABEL,
|
|
1193
|
-
auto_update_loaded: updaterLoaded,
|
|
1194
|
-
});
|
|
1195
|
-
}
|
|
1196
|
-
if (action === "enable") {
|
|
1197
|
-
// Bring up the on-screen overlay watcher so background autopilot cycles
|
|
1198
|
-
// still paint the harness status/sidebar. Idempotent + detached.
|
|
1199
|
-
await ensureOverlayWatch();
|
|
1200
|
-
// 1) Cycle plist. Write one pointing at the self-update guard ONLY if no
|
|
1201
|
-
// plist exists yet; never overwrite a hand-tuned/dev plist.
|
|
1202
|
-
const createdCycle = ensurePlist(TWITTER_AUTOPILOT_PLIST, plistXml({
|
|
1203
|
-
label: TWITTER_AUTOPILOT_LABEL,
|
|
1204
|
-
programArgs: ["/bin/bash", path.join(repoDir(), "skill", "run-cycle-update-guard.sh")],
|
|
1205
|
-
intervalSecs: 60,
|
|
1206
|
-
runAtLoad: false,
|
|
1207
|
-
stdoutLog: path.join(logDir, "launchd-twitter-cycle-stdout.log"),
|
|
1208
|
-
stderrLog: path.join(logDir, "launchd-twitter-cycle-stderr.log"),
|
|
1209
|
-
}));
|
|
1210
|
-
const cycleRes = await loadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
|
|
1211
|
-
// 2) Daily self-updater. Keeps a headless install current with no human
|
|
1212
|
-
// in the loop. RunAtLoad so it also checks shortly after enable.
|
|
1213
|
-
const createdUpdater = ensurePlist(UPDATER_PLIST, plistXml({
|
|
1214
|
-
label: UPDATER_LABEL,
|
|
1215
|
-
programArgs: ["/bin/bash", path.join(repoDir(), "skill", "social-autoposter-update.sh")],
|
|
1216
|
-
intervalSecs: 86_400,
|
|
1217
|
-
runAtLoad: true,
|
|
1218
|
-
stdoutLog: path.join(logDir, "launchd-self-update-stdout.log"),
|
|
1219
|
-
stderrLog: path.join(logDir, "launchd-self-update-stderr.log"),
|
|
1220
|
-
}));
|
|
1221
|
-
const updaterRes = await loadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
1222
|
-
return jsonContent({
|
|
1223
|
-
action: "enable",
|
|
1224
|
-
autopilot: {
|
|
1225
|
-
loaded: cycleRes.code === 0,
|
|
1226
|
-
plist: TWITTER_AUTOPILOT_PLIST,
|
|
1227
|
-
created: createdCycle,
|
|
1228
|
-
error: cycleRes.code === 0 ? null : (cycleRes.stderr || cycleRes.stdout).trim(),
|
|
1229
|
-
},
|
|
1230
|
-
auto_update: {
|
|
1231
|
-
loaded: updaterRes.code === 0,
|
|
1232
|
-
plist: UPDATER_PLIST,
|
|
1233
|
-
created: createdUpdater,
|
|
1234
|
-
note: "Daily updater enabled. It self-updates real npm installs and is a no-op on dev/source " +
|
|
1235
|
-
"checkouts (refuses to clobber a .git working tree).",
|
|
1236
|
-
error: updaterRes.code === 0 ? null : (updaterRes.stderr || updaterRes.stdout).trim(),
|
|
1237
|
-
},
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1240
|
-
// disable — unload both jobs (leave the plist files in place for re-enable)
|
|
1241
|
-
const cycleOff = await unloadPlist(TWITTER_AUTOPILOT_LABEL, TWITTER_AUTOPILOT_PLIST, uid);
|
|
1242
|
-
const updaterOff = await unloadPlist(UPDATER_LABEL, UPDATER_PLIST, uid);
|
|
1243
|
-
return jsonContent({
|
|
1244
|
-
action: "disable",
|
|
1245
|
-
autopilot_unloaded: cycleOff.code === 0,
|
|
1246
|
-
auto_update_unloaded: updaterOff.code === 0,
|
|
1247
|
-
note: cycleOff.code === 0
|
|
1248
|
-
? "Autopilot and daily auto-update unloaded. Manual draft_cycle still works."
|
|
1249
|
-
: `Autopilot disable reported exit ${cycleOff.code}: ${(cycleOff.stderr || cycleOff.stdout).trim()}`,
|
|
1250
|
-
});
|
|
1251
|
-
});
|
|
1234
|
+
// ---- autopilot: MCP tool removed ------------------------------------------
|
|
1235
|
+
// The `autopilot` MCP tool (enable/disable/status) was intentionally removed:
|
|
1236
|
+
// hands-free background posting is no longer toggled from the agent/tool surface.
|
|
1237
|
+
// The underlying launchd cycle job + plist (com.m13v.social-twitter-cycle) and
|
|
1238
|
+
// the daily self-updater are NOT touched here — an already-loaded job keeps
|
|
1239
|
+
// running, and the plist files stay on disk. The plist helpers above
|
|
1240
|
+
// (ensurePlist / plistXml / loadPlist / unloadPlist) and the constants are kept
|
|
1241
|
+
// as the underlying source for that job; the `dashboard` snapshot still reports
|
|
1242
|
+
// the job's loaded state via autopilotLoaded(). To enable/disable the job now,
|
|
1243
|
+
// use launchctl directly or re-add a tool here.
|
|
1252
1244
|
// ---- get_stats: read-only -------------------------------------------------
|
|
1253
1245
|
tool("get_stats", {
|
|
1254
1246
|
title: "Get X/Twitter stats",
|
|
@@ -1282,29 +1274,90 @@ tool("get_stats", {
|
|
|
1282
1274
|
}
|
|
1283
1275
|
});
|
|
1284
1276
|
// ---- version: report installed version + deliver updates on demand ---------
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1277
|
+
// ---- runtime: install + version/update + diagnostics ----------------------
|
|
1278
|
+
// ONE plumbing tool for the whole local-runtime lifecycle, action-based like
|
|
1279
|
+
// project_config and autopilot. The pipeline runs Python locally; rather than
|
|
1280
|
+
// depend on the user's system Python (the #1 source of install failures), the
|
|
1281
|
+
// first run provisions a fully OWNED uv runtime: standalone CPython + owned venv
|
|
1282
|
+
// + deps + Chromium. It also reports/installs new releases and runs the Doctor.
|
|
1283
|
+
// Plain (non-UI) so EVERY host can drive it — the panel's Install card and
|
|
1284
|
+
// Update button are just skins that call action:'install' then poll
|
|
1285
|
+
// action:'status'. See runtime.ts for the provisioning + progress contract.
|
|
1286
|
+
//
|
|
1287
|
+
// Actions:
|
|
1288
|
+
// status (default) — is the owned runtime installed? + in-progress step detail
|
|
1289
|
+
// install — start provisioning in the background; poll status to follow
|
|
1290
|
+
// version — installed vs latest published, whether an update is available
|
|
1291
|
+
// update — pull + install the latest release (npx social-autoposter@latest update)
|
|
1292
|
+
// doctor — run structured environment diagnostics (phase: pre_connect|full)
|
|
1293
|
+
// doctor_status — last persisted Doctor result without re-running checks
|
|
1294
|
+
tool("runtime", {
|
|
1295
|
+
title: "Runtime: install, update & diagnostics",
|
|
1296
|
+
description: "The ONE plumbing tool for the autoposter's local runtime lifecycle. action:'status' (default) " +
|
|
1297
|
+
"reports whether the self-contained Python/Chromium runtime is installed and, mid-install, the " +
|
|
1298
|
+
"per-step progress (uv, Python, venv, dependencies, Chromium) — poll it after action:'install'. " +
|
|
1299
|
+
"action:'install' provisions that runtime (a private Python via uv, NOT your system Python, plus " +
|
|
1300
|
+
"deps and Chromium); it runs in the background and returns immediately, is safe to call " +
|
|
1301
|
+
"repeatedly, and is a no-op once installed. action:'version' shows installed vs latest published " +
|
|
1302
|
+
"and whether an update is available; action:'update' pulls and installs the latest release (runs " +
|
|
1303
|
+
"`npx social-autoposter@latest update`, taking effect after the client reconnects/restarts). " +
|
|
1304
|
+
"action:'doctor' runs structured environment diagnostics (phase:'pre_connect' is safe at " +
|
|
1305
|
+
"onboarding start and treats the missing X session/cookies as expected; phase:'full' verifies the " +
|
|
1306
|
+
"completed environment after X is connected); action:'doctor_status' returns the last persisted " +
|
|
1307
|
+
"Doctor result without re-running. Use this the first time the user sets up, when another tool " +
|
|
1308
|
+
"reports the runtime isn't ready, when the user asks what version they're on or to update, or to " +
|
|
1309
|
+
"diagnose a broken environment.",
|
|
1293
1310
|
inputSchema: {
|
|
1294
|
-
action: z
|
|
1311
|
+
action: z
|
|
1312
|
+
.enum(["status", "install", "version", "update", "doctor", "doctor_status"])
|
|
1313
|
+
.optional(),
|
|
1314
|
+
phase: z
|
|
1315
|
+
.enum(["pre_connect", "full"])
|
|
1316
|
+
.optional()
|
|
1317
|
+
.describe("Only for action:'doctor' — which diagnostic phase to run (default pre_connect)."),
|
|
1295
1318
|
},
|
|
1296
|
-
}, async ({ action }) => {
|
|
1319
|
+
}, async ({ action, phase }) => {
|
|
1320
|
+
// ---- install: start provisioning the owned runtime --------------------
|
|
1321
|
+
if (action === "install") {
|
|
1322
|
+
if (runtimeReady()) {
|
|
1323
|
+
completeOnboardingMilestone("runtime_ready");
|
|
1324
|
+
return jsonContent({ already_installed: true, ...runtimeSnapshot() });
|
|
1325
|
+
}
|
|
1326
|
+
recordOnboardingAttempt("runtime_ready");
|
|
1327
|
+
const progress = startProvisioning();
|
|
1328
|
+
return jsonContent({
|
|
1329
|
+
started: true,
|
|
1330
|
+
runtime_ready: false,
|
|
1331
|
+
note: "Runtime install started. Poll runtime action:'status' every ~1.5s for progress.",
|
|
1332
|
+
progress,
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
// ---- version: installed vs latest published ---------------------------
|
|
1336
|
+
if (action === "version") {
|
|
1337
|
+
const v = await versionStatus();
|
|
1338
|
+
return jsonContent({
|
|
1339
|
+
installed: v.installed,
|
|
1340
|
+
latest_published: v.latest,
|
|
1341
|
+
update_available: v.update_available,
|
|
1342
|
+
update_command: "npx social-autoposter@latest update",
|
|
1343
|
+
note: v.latest == null
|
|
1344
|
+
? "Could not reach npm to check for a newer version (offline or registry error)."
|
|
1345
|
+
: v.update_available
|
|
1346
|
+
? `A newer version (${v.latest}) is available. Run this tool with action:'update' ` +
|
|
1347
|
+
"to install it, or run `npx social-autoposter@latest update` in a terminal."
|
|
1348
|
+
: "You are on the latest published version.",
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
// ---- update: pull + install the latest release ------------------------
|
|
1297
1352
|
if (action === "update") {
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
// non-interactively so it can't stall on a confirm prompt.
|
|
1353
|
+
// Overwrites mcp/dist/ (including this running file — safe; the loaded
|
|
1354
|
+
// process keeps old code) and re-runs install.mjs to re-register the
|
|
1355
|
+
// client config. npx is non-interactive so it can't stall on a confirm.
|
|
1302
1356
|
const before = VERSION;
|
|
1303
1357
|
const res = await run("npx", ["-y", "social-autoposter@latest", "update"], {
|
|
1304
1358
|
timeoutMs: 600_000,
|
|
1305
1359
|
});
|
|
1306
|
-
|
|
1307
|
-
const latest = await latestPublishedVersion();
|
|
1360
|
+
const latest = await latestPublishedVersion(); // bust the cache
|
|
1308
1361
|
return jsonContent({
|
|
1309
1362
|
action: "update",
|
|
1310
1363
|
ran: "npx social-autoposter@latest update",
|
|
@@ -1317,27 +1370,29 @@ tool("version", {
|
|
|
1317
1370
|
output_tail: (res.stdout + "\n" + res.stderr).trim().split("\n").slice(-20).join("\n"),
|
|
1318
1371
|
});
|
|
1319
1372
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
}
|
|
1373
|
+
// ---- doctor: run structured diagnostics -------------------------------
|
|
1374
|
+
if (action === "doctor") {
|
|
1375
|
+
const selected = phase || "pre_connect";
|
|
1376
|
+
const report = await runDoctorPhase(selected);
|
|
1377
|
+
return jsonContent({ doctor: report, onboarding: onboardingSnapshot() });
|
|
1378
|
+
}
|
|
1379
|
+
// ---- doctor_status: last persisted Doctor result ----------------------
|
|
1380
|
+
if (action === "doctor_status") {
|
|
1381
|
+
return jsonContent({
|
|
1382
|
+
doctor: onboardingLedger()?.doctor?.latest ?? null,
|
|
1383
|
+
onboarding: onboardingSnapshot(),
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
// ---- status (default): runtime install snapshot -----------------------
|
|
1387
|
+
const snapshot = runtimeSnapshot();
|
|
1388
|
+
if (snapshot.runtime_ready) {
|
|
1389
|
+
completeOnboardingMilestone("runtime_ready");
|
|
1390
|
+
}
|
|
1391
|
+
else if (snapshot.progress?.done && !snapshot.progress.ok) {
|
|
1392
|
+
blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
|
|
1393
|
+
}
|
|
1394
|
+
return jsonContent({ ...snapshot, onboarding: onboardingSnapshot() });
|
|
1333
1395
|
});
|
|
1334
|
-
// ---- runtime installer ----------------------------------------------------
|
|
1335
|
-
// The pipeline runs Python locally. Rather than depend on the user's system
|
|
1336
|
-
// Python (the #1 source of install failures), the first run provisions a fully
|
|
1337
|
-
// OWNED uv runtime: standalone CPython + owned venv + deps + Chromium. These two
|
|
1338
|
-
// tools drive it. They are plain (non-UI) tools so EVERY host can install — the
|
|
1339
|
-
// panel's Install card is just a skin that calls install_runtime then polls
|
|
1340
|
-
// install_status. See runtime.ts for the provisioning + progress contract.
|
|
1341
1396
|
function runtimeSnapshot() {
|
|
1342
1397
|
const rt = readRuntime();
|
|
1343
1398
|
const progress = readProgress();
|
|
@@ -1350,138 +1405,10 @@ function runtimeSnapshot() {
|
|
|
1350
1405
|
onboarding: onboardingSnapshot(),
|
|
1351
1406
|
};
|
|
1352
1407
|
}
|
|
1353
|
-
tool("install_runtime", {
|
|
1354
|
-
title: "Install the Python runtime",
|
|
1355
|
-
description: "One-time setup that provisions the self-contained runtime the autoposter needs: a private " +
|
|
1356
|
-
"Python (via uv, not your system Python), its dependencies, and the Chromium browser. Runs in " +
|
|
1357
|
-
"the background and returns immediately; poll `install_status` for progress. Safe to call " +
|
|
1358
|
-
"repeatedly; it resumes/repairs and is a no-op once everything is installed. Use this the " +
|
|
1359
|
-
"first time the user sets up, or if other tools report the runtime isn't ready.",
|
|
1360
|
-
inputSchema: {},
|
|
1361
|
-
}, async () => {
|
|
1362
|
-
if (runtimeReady()) {
|
|
1363
|
-
completeOnboardingMilestone("runtime_ready");
|
|
1364
|
-
return jsonContent({ already_installed: true, ...runtimeSnapshot() });
|
|
1365
|
-
}
|
|
1366
|
-
recordOnboardingAttempt("runtime_ready");
|
|
1367
|
-
const progress = startProvisioning();
|
|
1368
|
-
return jsonContent({
|
|
1369
|
-
started: true,
|
|
1370
|
-
runtime_ready: false,
|
|
1371
|
-
note: "Runtime install started. Poll install_status every ~1.5s for progress.",
|
|
1372
|
-
progress,
|
|
1373
|
-
});
|
|
1374
|
-
});
|
|
1375
|
-
tool("install_status", {
|
|
1376
|
-
title: "Runtime install status",
|
|
1377
|
-
description: "Report whether the self-contained Python/Chromium runtime is installed and, if an install is " +
|
|
1378
|
-
"in progress, the per-step progress (uv, Python, venv, dependencies, Chromium). Poll this after " +
|
|
1379
|
-
"install_runtime to follow the install to completion.",
|
|
1380
|
-
inputSchema: {},
|
|
1381
|
-
}, async () => {
|
|
1382
|
-
const snapshot = runtimeSnapshot();
|
|
1383
|
-
if (snapshot.runtime_ready) {
|
|
1384
|
-
completeOnboardingMilestone("runtime_ready");
|
|
1385
|
-
}
|
|
1386
|
-
else if (snapshot.progress?.done && !snapshot.progress.ok) {
|
|
1387
|
-
blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
|
|
1388
|
-
}
|
|
1389
|
-
return jsonContent({ ...snapshot, onboarding: onboardingSnapshot() });
|
|
1390
|
-
});
|
|
1391
|
-
// ---- doctor: structured environment diagnostics --------------------------
|
|
1392
|
-
// Uses the same shared engine as `npx social-autoposter doctor`. Pre-connect
|
|
1393
|
-
// avoids invasive keychain access and classifies not-yet-created X artifacts as
|
|
1394
|
-
// expected; full verifies the completed browser/session/cookie environment.
|
|
1395
|
-
tool("doctor", {
|
|
1396
|
-
title: "Diagnose the onboarding environment",
|
|
1397
|
-
description: "Run the structured social-autoposter Doctor and persist the result in the onboarding " +
|
|
1398
|
-
"ledger. phase:'pre_connect' is safe at onboarding start and treats the missing X session/" +
|
|
1399
|
-
"cookie artifacts as expected. phase:'full' verifies the completed environment after X is " +
|
|
1400
|
-
"connected. action:'status' returns the most recent persisted result without re-running checks.",
|
|
1401
|
-
inputSchema: {
|
|
1402
|
-
action: z.enum(["run", "status"]).optional(),
|
|
1403
|
-
phase: z.enum(["pre_connect", "full"]).optional(),
|
|
1404
|
-
},
|
|
1405
|
-
}, async ({ action, phase, }) => {
|
|
1406
|
-
if (action === "status") {
|
|
1407
|
-
return jsonContent({
|
|
1408
|
-
doctor: onboardingLedger()?.doctor?.latest ?? null,
|
|
1409
|
-
onboarding: onboardingSnapshot(),
|
|
1410
|
-
});
|
|
1411
|
-
}
|
|
1412
|
-
const selected = phase || "pre_connect";
|
|
1413
|
-
const report = await runDoctorPhase(selected);
|
|
1414
|
-
return jsonContent({
|
|
1415
|
-
doctor: report,
|
|
1416
|
-
onboarding: onboardingSnapshot(),
|
|
1417
|
-
});
|
|
1418
|
-
});
|
|
1419
|
-
// ---- config: read / edit the raw config.json ------------------------------
|
|
1420
|
-
// The panel renders the full config and lets the user edit it. Writing is
|
|
1421
|
-
// guarded: the new content must parse as JSON, and we always drop a timestamped
|
|
1422
|
-
// backup next to config.json before overwriting, so a bad paste is recoverable.
|
|
1423
|
-
tool("config", {
|
|
1424
|
-
title: "View or edit config.json",
|
|
1425
|
-
description: "Read or update the autoposter's config.json (the source of truth for every project, the X/" +
|
|
1426
|
-
"Reddit/LinkedIn account handles, topics, and exclusions). action:'get' (default) returns the " +
|
|
1427
|
-
"full raw JSON; action:'save' validates the supplied `content` as JSON, writes a timestamped " +
|
|
1428
|
-
"backup, then overwrites config.json. Use when the user asks to see, edit, or fix their config.",
|
|
1429
|
-
inputSchema: {
|
|
1430
|
-
action: z.enum(["get", "save"]).optional(),
|
|
1431
|
-
content: z.string().optional(),
|
|
1432
|
-
},
|
|
1433
|
-
}, async (args) => {
|
|
1434
|
-
const action = args.action || "get";
|
|
1435
|
-
const cfgPath = configPath();
|
|
1436
|
-
if (action === "get") {
|
|
1437
|
-
try {
|
|
1438
|
-
const content = fs.readFileSync(cfgPath, "utf-8");
|
|
1439
|
-
return jsonContent({ ok: true, path: cfgPath, bytes: content.length, content });
|
|
1440
|
-
}
|
|
1441
|
-
catch (e) {
|
|
1442
|
-
return jsonContent({ ok: false, path: cfgPath, error: String(e?.message || e) });
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
// save
|
|
1446
|
-
const content = args.content;
|
|
1447
|
-
if (typeof content !== "string" || content.trim() === "") {
|
|
1448
|
-
return jsonContent({ ok: false, error: "Nothing to save: `content` was empty." });
|
|
1449
|
-
}
|
|
1450
|
-
let parsed;
|
|
1451
|
-
try {
|
|
1452
|
-
parsed = JSON.parse(content);
|
|
1453
|
-
}
|
|
1454
|
-
catch (e) {
|
|
1455
|
-
// Don't write a config that won't parse — every pipeline reads this file.
|
|
1456
|
-
return jsonContent({ ok: false, error: "Invalid JSON, not saved: " + String(e?.message || e) });
|
|
1457
|
-
}
|
|
1458
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1459
|
-
return jsonContent({ ok: false, error: "Top level of config.json must be a JSON object." });
|
|
1460
|
-
}
|
|
1461
|
-
try {
|
|
1462
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1463
|
-
const backup = `${cfgPath}.bak-panel-${stamp}`;
|
|
1464
|
-
try {
|
|
1465
|
-
fs.copyFileSync(cfgPath, backup);
|
|
1466
|
-
}
|
|
1467
|
-
catch {
|
|
1468
|
-
/* first-write / missing original is non-fatal */
|
|
1469
|
-
}
|
|
1470
|
-
// Re-serialize the parsed object so what lands on disk is canonical,
|
|
1471
|
-
// 2-space-indented JSON with a trailing newline (matches the Python
|
|
1472
|
-
// writers), regardless of how the user formatted their paste.
|
|
1473
|
-
const out = JSON.stringify(parsed, null, 2) + "\n";
|
|
1474
|
-
fs.writeFileSync(cfgPath, out, "utf-8");
|
|
1475
|
-
return jsonContent({ ok: true, path: cfgPath, bytes: out.length, backup });
|
|
1476
|
-
}
|
|
1477
|
-
catch (e) {
|
|
1478
|
-
return jsonContent({ ok: false, error: "Write failed: " + String(e?.message || e) });
|
|
1479
|
-
}
|
|
1480
|
-
});
|
|
1481
1408
|
// ---- panel: MCP Apps control surface --------------------------------------
|
|
1482
1409
|
// A self-contained HTML view rendered by hosts that support MCP Apps (Claude
|
|
1483
1410
|
// desktop/web, etc.). It duplicates NO pipeline logic: each button calls one of
|
|
1484
|
-
// the tools above (draft_cycle /
|
|
1411
|
+
// the tools above (draft_cycle / project_config / get_stats) through the host
|
|
1485
1412
|
// and re-reads status. The tool itself returns the first-paint snapshot so the
|
|
1486
1413
|
// view has data the instant it loads.
|
|
1487
1414
|
// Is either launchd job (cycle / daily updater) currently loaded?
|
|
@@ -1641,19 +1568,64 @@ function startLocalPanel() {
|
|
|
1641
1568
|
}
|
|
1642
1569
|
});
|
|
1643
1570
|
srv.on("error", reject);
|
|
1644
|
-
|
|
1571
|
+
// Optional fixed port (SAPS_PANEL_PORT) for deterministic addressing; default
|
|
1572
|
+
// is an OS-assigned ephemeral port.
|
|
1573
|
+
const wantPort = Number(process.env.SAPS_PANEL_PORT) || 0;
|
|
1574
|
+
srv.listen(wantPort, "127.0.0.1", () => {
|
|
1645
1575
|
const addr = srv.address();
|
|
1646
1576
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1647
1577
|
localPanel = { url: `http://127.0.0.1:${port}/`, server: srv };
|
|
1578
|
+
writePanelUrl(localPanel.url);
|
|
1648
1579
|
resolve(localPanel.url);
|
|
1649
1580
|
});
|
|
1650
1581
|
});
|
|
1651
1582
|
}
|
|
1652
|
-
//
|
|
1653
|
-
//
|
|
1654
|
-
//
|
|
1583
|
+
// Publish the loopback URL to stable files so out-of-process readers can find
|
|
1584
|
+
// the ephemeral port without scraping `lsof`:
|
|
1585
|
+
// - panel-url plain text, for the Claude Code side-panel reverse proxy.
|
|
1586
|
+
// - panel-endpoint.json richer (url + version + pid), for the menu bar app,
|
|
1587
|
+
// which POSTs /tool/<name> here for live data.
|
|
1588
|
+
// Best-effort: a write failure never blocks the panel (readers re-check /health).
|
|
1589
|
+
function writePanelUrl(url) {
|
|
1590
|
+
try {
|
|
1591
|
+
const dir = path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp");
|
|
1592
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1593
|
+
fs.writeFileSync(path.join(dir, "panel-url"), url, "utf-8");
|
|
1594
|
+
fs.writeFileSync(path.join(dir, "panel-endpoint.json"), JSON.stringify({ url, pid: process.pid, version: VERSION, started_at: new Date().toISOString() }, null, 2) + "\n", "utf-8");
|
|
1595
|
+
}
|
|
1596
|
+
catch (e) {
|
|
1597
|
+
console.error("[social-autoposter-mcp] writePanelUrl failed:", e?.message || e);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
// The owned state dir, honoring SAPS_STATE_DIR (matches menubar/s4l_state.py).
|
|
1601
|
+
function sapsStateDir() {
|
|
1602
|
+
return (process.env.SAPS_STATE_DIR ||
|
|
1603
|
+
path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp"));
|
|
1604
|
+
}
|
|
1605
|
+
// Signal the menu bar that a fresh draft batch is ready for pop-up review. The
|
|
1606
|
+
// chat-table review path is unchanged and still works; this just ALSO lets the
|
|
1607
|
+
// corner cards drive review (both surfaces de-dup via the plan's `posted` flag).
|
|
1608
|
+
// The menu bar reads review-request.json, presents the cards, posts via the
|
|
1609
|
+
// loopback post_drafts tool, then clears the file. Best-effort: a write failure
|
|
1610
|
+
// just means no pop-ups this batch (chat review still works).
|
|
1611
|
+
function writeReviewRequest(req) {
|
|
1612
|
+
try {
|
|
1613
|
+
const dir = sapsStateDir();
|
|
1614
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1615
|
+
fs.writeFileSync(path.join(dir, "review-request.json"), JSON.stringify(req, null, 2) + "\n", "utf-8");
|
|
1616
|
+
}
|
|
1617
|
+
catch (e) {
|
|
1618
|
+
console.error("[social-autoposter-mcp] writeReviewRequest failed:", e?.message || e);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
// Open a URL in the user's default browser, cross-platform. Opening is OPT-IN:
|
|
1622
|
+
// by default we do NOT pop a browser tab. The dashboard already surfaces in-host
|
|
1623
|
+
// (MCP Apps inline) or via the Claude Code side panel / returned loopback URL, so
|
|
1624
|
+
// auto-opening the OS browser on every dashboard call is unwanted noise. Set
|
|
1625
|
+
// SAPS_PANEL_OPEN_BROWSER=1 to restore the old auto-open behavior. (The URL is
|
|
1626
|
+
// always returned to the caller regardless, so nothing is lost when we don't open.)
|
|
1655
1627
|
async function openInBrowser(url) {
|
|
1656
|
-
if (process.env.
|
|
1628
|
+
if (!process.env.SAPS_PANEL_OPEN_BROWSER)
|
|
1657
1629
|
return;
|
|
1658
1630
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
1659
1631
|
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
@@ -1667,10 +1639,10 @@ async function openInBrowser(url) {
|
|
|
1667
1639
|
appTool("dashboard", {
|
|
1668
1640
|
title: "Social Autoposter dashboard",
|
|
1669
1641
|
description: "Render the Social Autoposter dashboard in chat: a visual surface showing project setup, X " +
|
|
1670
|
-
"connection, autopilot state, and 7-day stats, with buttons to run a draft cycle,
|
|
1671
|
-
"
|
|
1642
|
+
"connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, connect X, " +
|
|
1643
|
+
"and refresh. Use when the user asks to see the dashboard, panel, " +
|
|
1672
1644
|
"status, or controls. ALSO call this at the end of any state-changing or results-producing " +
|
|
1673
|
-
"action (draft_cycle, post_drafts,
|
|
1645
|
+
"action (draft_cycle, post_drafts, get_stats) so the user sees the " +
|
|
1674
1646
|
"updated dashboard. Hosts without UI support get the same data as text.",
|
|
1675
1647
|
inputSchema: {},
|
|
1676
1648
|
// fallback_url is set only when the host can't render the ui:// resource and
|
|
@@ -1699,8 +1671,9 @@ appTool("dashboard", {
|
|
|
1699
1671
|
return { content: [], structuredContent: { snapshot: JSON.stringify(snap) } };
|
|
1700
1672
|
}
|
|
1701
1673
|
// Host CAN'T render inline (Claude Code / Cowork today): serve the identical
|
|
1702
|
-
// panel.html from a loopback HTTP server
|
|
1703
|
-
//
|
|
1674
|
+
// panel.html from a loopback HTTP server. We do NOT auto-open a browser tab
|
|
1675
|
+
// (see openInBrowser — opt-in only); the dashboard is shown in the Claude Code
|
|
1676
|
+
// side panel, and the loopback URL is returned for anyone who wants to open it.
|
|
1704
1677
|
try {
|
|
1705
1678
|
const url = await startLocalPanel();
|
|
1706
1679
|
await openInBrowser(url);
|
|
@@ -1708,7 +1681,7 @@ appTool("dashboard", {
|
|
|
1708
1681
|
content: [{
|
|
1709
1682
|
type: "text",
|
|
1710
1683
|
text: human +
|
|
1711
|
-
`\n\nThis host can't render the dashboard inline
|
|
1684
|
+
`\n\nThis host can't render the dashboard inline. It's available in the side panel; loopback URL: ${url}`,
|
|
1712
1685
|
}],
|
|
1713
1686
|
structuredContent: { snapshot: JSON.stringify(snap), fallback_url: url },
|
|
1714
1687
|
};
|
|
@@ -1804,6 +1777,18 @@ async function main() {
|
|
|
1804
1777
|
const transport = new StdioServerTransport();
|
|
1805
1778
|
await server.connect(transport);
|
|
1806
1779
|
console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
|
|
1780
|
+
// Eagerly start the loopback panel server so the Claude Code side panel (and any
|
|
1781
|
+
// reverse proxy in front of it) always has a backend to hit, without waiting for
|
|
1782
|
+
// a first `dashboard` call. Best-effort: a bind failure must never block boot.
|
|
1783
|
+
void startLocalPanel()
|
|
1784
|
+
.then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
|
|
1785
|
+
.catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
|
|
1786
|
+
// Ensure the macOS menu bar mini-dashboard is installed + running. Idempotent
|
|
1787
|
+
// and cheap when already present, so existing installs pick it up on the next
|
|
1788
|
+
// Claude restart without re-provisioning. Best-effort: never blocks boot.
|
|
1789
|
+
void ensureMenubar()
|
|
1790
|
+
.then((r) => console.error(`[social-autoposter-mcp] menubar: ${r.skipped ? "skip" : r.ok ? "ok" : "fail"} (${r.detail})`))
|
|
1791
|
+
.catch((e) => console.error("[social-autoposter-mcp] menubar ensure failed:", e?.message || e));
|
|
1807
1792
|
// Phone home so this .mcpb install is visible in the install-lane digest
|
|
1808
1793
|
// (parity with the npx launchd heartbeat). Once on startup, then every 15m
|
|
1809
1794
|
// while the desktop app keeps the server alive. unref() so it never holds the
|