social-autoposter 1.6.70 → 1.6.71

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/bin/cli.js CHANGED
@@ -367,123 +367,6 @@ function applyAppMakerMcpConfigOverrides() {
367
367
  // uv installed and broke Phase 1's Claude scan (the MCP server's `command:
368
368
  // /root/.local/bin/uv` resolved to ENOENT, Claude got no tools, returned an
369
369
  // empty envelope).
370
- // AppMaker VM self-bootstrap. Single entry point that the appmaker template
371
- // startup.sh calls on every fresh sandbox boot. Reads the stable sessionKey
372
- // from /run/mk0r-session.json (which the appmaker bridge rewrites on every
373
- // session bind, and which survives E2B sandbox substitution — only the
374
- // sandboxId changes), then asks the social-autoposter HTTP API which Twitter
375
- // account this VM is bound to (handle + stored login cookies, keyed by
376
- // social_accounts.vm_session_key). With that single DB answer it sets up
377
- // everything: env file (with the DB-sourced handle), profile symlink, MCP
378
- // config (BH_PORT=9222), uuid-runtime, then restores the Twitter login by
379
- // re-injecting the stored cookies via CDP.
380
- //
381
- // This is the "one proper fix" for sandbox substitution: the VM holds no
382
- // per-VM state on disk — the DB does, keyed by the stable sessionKey. So
383
- // any fresh sandbox can rebuild itself by reading /run/mk0r-session.json
384
- // and calling one route.
385
- function bootstrapVm() {
386
- if (!isAppMakerVm()) {
387
- console.error('bootstrap-vm: not an AppMaker VM (no /opt/startup.sh + CDP :9222). Use `init` or `update` on dev boxes.');
388
- process.exit(2);
389
- }
390
- // Persist VM mode so subsequent `update` runs on this sandbox keep the
391
- // AppMaker tweaks without re-passing --vm/SA_VM.
392
- enableVmMode();
393
- console.log(' AppMaker VM bootstrap: resolving identity from DB by sessionKey...');
394
-
395
- let sessionKey;
396
- try {
397
- const raw = fs.readFileSync('/run/mk0r-session.json', 'utf8');
398
- sessionKey = (JSON.parse(raw) || {}).sessionKey;
399
- } catch (err) {
400
- console.error(`bootstrap-vm: cannot read /run/mk0r-session.json: ${err.message}`);
401
- process.exit(3);
402
- }
403
- if (!sessionKey) {
404
- console.error('bootstrap-vm: /run/mk0r-session.json has no sessionKey');
405
- process.exit(3);
406
- }
407
- console.log(` sessionKey=${sessionKey}`);
408
-
409
- // Get the X-Installation header via identity.py (same Python helper http_api.py
410
- // uses, so auth stays single-sourced).
411
- const identityPath = path.join(PKG_ROOT, 'scripts', 'identity.py');
412
- const headerRes = spawnSync('/usr/bin/python3', [identityPath, 'header'], {
413
- encoding: 'utf8',
414
- });
415
- if (headerRes.status !== 0) {
416
- console.error(`bootstrap-vm: identity.py header failed: ${headerRes.stderr || headerRes.error}`);
417
- process.exit(4);
418
- }
419
- const installHeader = (headerRes.stdout || '').trim();
420
-
421
- const base = (process.env.AUTOPOSTER_API_BASE || 'https://s4l.ai').replace(/\/+$/, '');
422
- const url = `${base}/api/v1/twitter/vm-session?session_key=${encodeURIComponent(sessionKey)}`;
423
- console.log(` GET ${url}`);
424
-
425
- // Use curl (always present on the appmaker template) so we don't pull in
426
- // a Node HTTP dep here.
427
- const curl = spawnSync('curl', [
428
- '-sS', '--max-time', '15',
429
- '-H', `X-Installation: ${installHeader}`,
430
- '-H', 'Content-Type: application/json',
431
- url,
432
- ], { encoding: 'utf8' });
433
- if (curl.status !== 0) {
434
- console.error(`bootstrap-vm: curl failed: ${curl.stderr || curl.error}`);
435
- process.exit(5);
436
- }
437
- let payload;
438
- try {
439
- payload = JSON.parse(curl.stdout || '{}');
440
- } catch (err) {
441
- console.error(`bootstrap-vm: bad JSON from API: ${curl.stdout.slice(0, 300)}`);
442
- process.exit(6);
443
- }
444
- if (!payload.ok || !payload.data) {
445
- console.error(`bootstrap-vm: API error: ${JSON.stringify(payload).slice(0, 300)}`);
446
- process.exit(7);
447
- }
448
- const { handle, cookies, vm_project_id } = payload.data;
449
- if (!handle) {
450
- console.error('bootstrap-vm: API returned no handle. social_accounts.vm_session_key may be unset for this VM.');
451
- process.exit(8);
452
- }
453
- console.log(` bound to @${handle} (vm_project_id=${vm_project_id || 'none'}, cookies=${(cookies || []).length})`);
454
-
455
- // Write env file with DB-sourced handle (durable across `social-autoposter update`).
456
- writeAppMakerEnvFile(handle);
457
-
458
- // Existing setup steps. installBrowserHarness already installs uuid-runtime,
459
- // symlinks the profile, and patches the MCP config — call it directly.
460
- installBrowserHarness();
461
-
462
- // Install Python deps from requirements.txt. installBrowserHarness only
463
- // installs uv + mcp; it does NOT read requirements.txt, so without this the
464
- // VM is missing websocket-client (restore_twitter_session.py aborts on
465
- // import) plus playwright that the cycle scripts need.
466
- installPythonDeps();
467
-
468
- // Restore the Twitter login if we have stored cookies and the Chrome is
469
- // up. No-op when Chrome isn't reachable yet (startup ordering); the cycle
470
- // preflight will run restore_twitter_session.py on its next tick.
471
- if ((cookies || []).length > 0) {
472
- const restorePath = path.join(HOME, 'social-autoposter', 'scripts', 'restore_twitter_session.py');
473
- if (fs.existsSync(restorePath)) {
474
- console.log(' invoking restore_twitter_session.py to re-inject cookies...');
475
- // Source the env file so AUTOPOSTER_TWITTER_HANDLE / TWITTER_CDP_URL are set.
476
- const r = spawnSync('bash', ['-lc',
477
- `source ${HOME}/.social-autoposter-env 2>/dev/null; /usr/bin/python3 ${restorePath} || true`,
478
- ], { stdio: 'inherit' });
479
- void r;
480
- }
481
- } else {
482
- console.log(' no stored cookies; manual login still required this once.');
483
- }
484
-
485
- console.log(' bootstrap-vm: done.');
486
- }
487
370
 
488
371
  function installBrowserHarness() {
489
372
  const onAppMaker = vmModeEnabled();
@@ -1137,8 +1020,6 @@ if (cmd === 'init') {
1137
1020
  update();
1138
1021
  } else if (cmd === 'doctor') {
1139
1022
  doctor();
1140
- } else if (cmd === 'bootstrap-vm') {
1141
- bootstrapVm();
1142
1023
  } else if (cmd === 'install-runtime') {
1143
1024
  installRuntime();
1144
1025
  } else if (cmd === 'reset' || cmd === 'uninstall') {
@@ -1171,7 +1052,6 @@ if (cmd === 'init') {
1171
1052
  console.log(' npx social-autoposter init first-time setup');
1172
1053
  console.log(' npx social-autoposter update update scripts, preserve config');
1173
1054
  console.log(' npx social-autoposter doctor [--json] [--phase pre_connect|full]');
1174
- console.log(' npx social-autoposter bootstrap-vm AppMaker VM self-bootstrap (DB-driven)');
1175
1055
  console.log(' npx social-autoposter install-runtime provision owned Python + Chromium (panel-free)');
1176
1056
  console.log(' npx social-autoposter export-cookies [dir] export browser cookies');
1177
1057
  console.log(' npx social-autoposter import-cookies [dir] import browser cookies');
package/mcp/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { screencast, bringBrowserToFront } from "./screencast.js";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
18
  import fs from "node:fs";
19
- import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
19
+ import { repoDir, runPython, run, readPlan, writePlan, planPath, scanResultPath, } from "./repo.js";
20
20
  import { applySetup, resolveProject, listManagedProjectStatus, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, } from "./setup.js";
21
21
  import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
22
22
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, } from "./runtime.js";
@@ -190,9 +190,31 @@ const baseRegisterTool = server.registerTool.bind(server);
190
190
  // same input-schema -> callback-arg inference it had before; the body is `any`
191
191
  // and just additionally stashes the callback by name. `appTool` drops the
192
192
  // leading `server` arg of registerAppTool (its callback takes no typed args).
193
+ // Tools that take a while: writing activity.json around them makes the menu bar
194
+ // show a spinner + label while they run (either invocation path). draft_cycle is
195
+ // NOT here — it writes finer scanning/drafting phases itself (see produceDrafts).
196
+ const TOOL_ACTIVITY = {
197
+ post_drafts: "posting",
198
+ get_stats: "loading stats",
199
+ };
200
+ function withActivity(name, cb) {
201
+ const label = TOOL_ACTIVITY[name];
202
+ if (!label)
203
+ return cb;
204
+ return async (args, extra) => {
205
+ writeActivity("working", label);
206
+ try {
207
+ return await cb(args, extra);
208
+ }
209
+ finally {
210
+ clearActivity();
211
+ }
212
+ };
213
+ }
193
214
  const tool = ((name, config, cb) => {
194
- TOOL_HANDLERS[name] = cb;
195
- return baseRegisterTool(name, config, cb);
215
+ const h = withActivity(name, cb);
216
+ TOOL_HANDLERS[name] = h;
217
+ return baseRegisterTool(name, config, h);
196
218
  });
197
219
  const appTool = ((name, config, cb) => {
198
220
  // Wrap every tool handler so any thrown error is reported to Sentry. Single
@@ -208,8 +230,9 @@ const appTool = ((name, config, cb) => {
208
230
  throw e;
209
231
  }
210
232
  });
211
- TOOL_HANDLERS[name] = wrapped;
212
- return registerAppTool(server, name, config, wrapped);
233
+ const h = withActivity(name, wrapped);
234
+ TOOL_HANDLERS[name] = h;
235
+ return registerAppTool(server, name, config, h);
213
236
  });
214
237
  function jsonContent(obj) {
215
238
  return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
@@ -371,6 +394,9 @@ async function produceDrafts(project, onProgress) {
371
394
  }
372
395
  appendLog(`\n===== draft_cycle start ${new Date().toISOString()} ` +
373
396
  `project=${project ?? "(default)"} =====\n`);
397
+ // Menu-bar status: scanning first, then drafting once the prep phase begins
398
+ // (switched in onLine below). Cleared before every return.
399
+ writeActivity("scanning", "scanning X");
374
400
  const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
375
401
  env,
376
402
  timeoutMs: 900_000, // scan+draft can take several minutes
@@ -386,6 +412,8 @@ async function produceDrafts(project, onProgress) {
386
412
  appendLog(`${t}\n`);
387
413
  console.error(`[draft_cycle] ${t}`);
388
414
  }
415
+ if (/Phase 2b-prep/.test(t))
416
+ writeActivity("drafting", "drafting replies");
389
417
  if (!onProgress)
390
418
  return;
391
419
  const msg = cycleProgressMessage(t);
@@ -399,13 +427,16 @@ async function produceDrafts(project, onProgress) {
399
427
  appendLog(`===== draft_cycle end ${new Date().toISOString()} exit=${res.code} =====\n`);
400
428
  // Prefer the explicit marker; fall back to the newest plan file on disk.
401
429
  const marker = /DRAFT_ONLY_PLAN=\/tmp\/twitter_cycle_plan_(.+)\.json/.exec(res.stdout + "\n" + res.stderr);
402
- if (marker && marker[1])
430
+ if (marker && marker[1]) {
431
+ clearActivity();
403
432
  return { batchId: marker[1] };
433
+ }
404
434
  // A real prep-step failure (e.g. the background claude CLI isn't logged in)
405
435
  // emits DRAFT_ONLY_BLOCKED=<reason>. Surface that instead of silently falling
406
436
  // back to a stale/empty batch and mis-reporting "no fresh candidates".
407
437
  const blockedMarker = /DRAFT_ONLY_BLOCKED=([a-z0-9_]+)/.exec(res.stdout + "\n" + res.stderr);
408
438
  if (blockedMarker && blockedMarker[1]) {
439
+ clearActivity();
409
440
  return { batchId: null, blocked: blockedReasonMessage(blockedMarker[1]) };
410
441
  }
411
442
  // No `DRAFT_ONLY_PLAN=` marker from THIS run => this run produced no drafts.
@@ -413,6 +444,7 @@ async function produceDrafts(project, onProgress) {
413
444
  // that's a *previous* run's batch, so a 5-second empty cycle would echo an old
414
445
  // 7-draft batch and report phantom success. Report 0 drafts honestly, with the
415
446
  // pipeline's own reason (e.g. cold-start project with no seeded queries).
447
+ clearActivity();
416
448
  return {
417
449
  batchId: null,
418
450
  blocked: `This run produced no drafts (exit ${res.code}). The scan found no fresh ` +
@@ -1602,6 +1634,27 @@ function sapsStateDir() {
1602
1634
  return (process.env.SAPS_STATE_DIR ||
1603
1635
  path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp"));
1604
1636
  }
1637
+ // activity.json: a tiny "what's running right now" signal the menu bar reads to
1638
+ // show a loading spinner + label (scanning / drafting / posting / …). Written by
1639
+ // long-running tools, cleared when they finish. Best-effort; absence == idle.
1640
+ function writeActivity(state, label) {
1641
+ try {
1642
+ const dir = sapsStateDir();
1643
+ fs.mkdirSync(dir, { recursive: true });
1644
+ fs.writeFileSync(path.join(dir, "activity.json"), JSON.stringify({ state, label, since: new Date().toISOString() }) + "\n", "utf-8");
1645
+ }
1646
+ catch {
1647
+ /* best effort: a status write must never break the work it's narrating */
1648
+ }
1649
+ }
1650
+ function clearActivity() {
1651
+ try {
1652
+ fs.rmSync(path.join(sapsStateDir(), "activity.json"), { force: true });
1653
+ }
1654
+ catch {
1655
+ /* best effort */
1656
+ }
1657
+ }
1605
1658
  // Signal the menu bar that a fresh draft batch is ready for pop-up review. The
1606
1659
  // chat-table review path is unchanged and still works; this just ALSO lets the
1607
1660
  // corner cards drive review (both surfaces de-dup via the plan's `posted` flag).
@@ -1636,6 +1689,178 @@ async function openInBrowser(url) {
1636
1689
  console.error("[social-autoposter-mcp] openInBrowser failed:", e?.message || e);
1637
1690
  }
1638
1691
  }
1692
+ async function runScanCandidates(project, onProgress) {
1693
+ // SCAN_ONLY=1: run scan -> score -> top-N select, then STOP before drafting.
1694
+ // No DRAFT_ONLY, no `claude -p` drafting. TWITTER_PHASE1_LLM_DRAFT=0 forces the
1695
+ // deterministic query bank so the whole scan is claude-free (the agent does ALL
1696
+ // the AI). Off-screen by default (no BH_WINDOW_* / overlay): a scheduled run has
1697
+ // no human watching the Chrome.
1698
+ const env = {
1699
+ SCAN_ONLY: "1",
1700
+ TWITTER_PHASE1_LLM_DRAFT: "0",
1701
+ SAPS_REPO_DIR: repoDir(),
1702
+ PATH: pipelinePath(),
1703
+ };
1704
+ if (project)
1705
+ env.SAPS_FORCE_PROJECT = project;
1706
+ const chrome = resolveChrome();
1707
+ if (chrome)
1708
+ env.BH_CHROME_BIN = chrome;
1709
+ let step = 0;
1710
+ let lastMsg = "";
1711
+ const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
1712
+ env,
1713
+ timeoutMs: 900_000,
1714
+ onLine: (line) => {
1715
+ const t = line.replace(/\s+$/, "");
1716
+ if (t.trim())
1717
+ console.error(`[scan_candidates] ${t}`);
1718
+ if (!onProgress)
1719
+ return;
1720
+ const msg = cycleProgressMessage(t);
1721
+ if (msg && msg !== lastMsg) {
1722
+ lastMsg = msg;
1723
+ onProgress(msg, ++step);
1724
+ }
1725
+ },
1726
+ });
1727
+ const marker = /SCAN_ONLY_RESULT=(\/\S+\.json)/.exec(res.stdout + "\n" + res.stderr);
1728
+ if (marker && marker[1]) {
1729
+ try {
1730
+ const data = JSON.parse(fs.readFileSync(marker[1], "utf-8"));
1731
+ return {
1732
+ batchId: data.batch_id ?? null,
1733
+ candidates: (data.candidates ?? []),
1734
+ };
1735
+ }
1736
+ catch (e) {
1737
+ return {
1738
+ batchId: null,
1739
+ candidates: [],
1740
+ blocked: `Scan finished but its result file was unreadable: ${e?.message || e}`,
1741
+ };
1742
+ }
1743
+ }
1744
+ // If query-gen (when enabled) hits a real failure, the cycle still emits
1745
+ // DRAFT_ONLY_BLOCKED=<reason>. Surface it rather than "no candidates".
1746
+ const blockedMarker = /DRAFT_ONLY_BLOCKED=([a-z0-9_]+)/.exec(res.stdout + "\n" + res.stderr);
1747
+ if (blockedMarker && blockedMarker[1]) {
1748
+ return { batchId: null, candidates: [], blocked: blockedReasonMessage(blockedMarker[1]) };
1749
+ }
1750
+ return {
1751
+ batchId: null,
1752
+ candidates: [],
1753
+ blocked: `This scan produced no candidates (exit ${res.code}). Usually a cold-start ` +
1754
+ `project with no seeded search topics, or nothing fresh on-theme right now. Tail:\n` +
1755
+ res.stderr.split("\n").slice(-12).join("\n"),
1756
+ };
1757
+ }
1758
+ tool("scan_candidates", {
1759
+ title: "Scan X for reply candidates (no drafting, no posting)",
1760
+ description: "Step 1 of a hands-free / scheduled autopilot run. Runs the scan+score half of the pipeline " +
1761
+ "and returns the top scored X/Twitter threads worth replying to — WITHOUT drafting or posting, " +
1762
+ "and without spending any `claude -p` budget. You (this session) then draft an on-brand reply " +
1763
+ "for each good candidate YOURSELF and submit them with `submit_drafts`. Each candidate includes " +
1764
+ "its candidate_id (pass it back), the thread text/author, the matched project, and engagement " +
1765
+ "metrics. Optional `project` scopes the scan to one configured project.",
1766
+ inputSchema: { project: z.string().optional() },
1767
+ }, async (args) => {
1768
+ const scan = await runScanCandidates(args?.project);
1769
+ if (!scan.batchId) {
1770
+ return textContent(scan.blocked || "No candidates found.");
1771
+ }
1772
+ return jsonContent({
1773
+ batch_id: scan.batchId,
1774
+ count: scan.candidates.length,
1775
+ candidates: scan.candidates,
1776
+ next_step: `Draft an on-brand reply (<=250 chars, match the thread's language) for each candidate you ` +
1777
+ `judge genuinely worth engaging; skip the rest. Then call submit_drafts with batch_id ` +
1778
+ `"${scan.batchId}" and one entry per drafted reply ({candidate_id, reply_text}). Nothing posts ` +
1779
+ `until the user approves.`,
1780
+ });
1781
+ });
1782
+ tool("submit_drafts", {
1783
+ title: "Submit drafted replies for review",
1784
+ description: "Step 2 of a hands-free / scheduled autopilot run. Hand back the replies you drafted for the " +
1785
+ "candidates returned by scan_candidates. Writes them into the SAME review plan the menu-bar " +
1786
+ "approval UI and post_drafts already use — nothing is posted until the user approves. Provide " +
1787
+ "batch_id (from scan_candidates) and a `drafts` array; each entry needs candidate_id and " +
1788
+ "reply_text (engagement_style, language, link_keyword optional).",
1789
+ inputSchema: {
1790
+ batch_id: z.string(),
1791
+ drafts: z
1792
+ .array(z.object({
1793
+ candidate_id: z.union([z.string(), z.number()]),
1794
+ reply_text: z.string(),
1795
+ engagement_style: z.string().optional(),
1796
+ language: z.string().optional(),
1797
+ link_keyword: z.string().optional(),
1798
+ }))
1799
+ .min(1),
1800
+ },
1801
+ }, async (args) => {
1802
+ // Reload the scan candidates for thread metadata (url / author / text /
1803
+ // project / topic). If the scan file is gone (e.g. /tmp cleared), we still
1804
+ // build a plan from the drafts alone; the review cards just lack context.
1805
+ const scanById = new Map();
1806
+ try {
1807
+ const raw = JSON.parse(fs.readFileSync(scanResultPath(args.batch_id), "utf-8"));
1808
+ for (const c of (raw.candidates ?? [])) {
1809
+ scanById.set(String(c.id), c);
1810
+ }
1811
+ }
1812
+ catch {
1813
+ /* scan file missing — proceed with draft-only context */
1814
+ }
1815
+ const candidates = args.drafts.map((d) => {
1816
+ const sc = scanById.get(String(d.candidate_id));
1817
+ return {
1818
+ candidate_id: d.candidate_id,
1819
+ candidate_url: sc?.tweet_url,
1820
+ thread_author: sc?.author_handle,
1821
+ thread_text: sc?.tweet_text,
1822
+ reply_text: d.reply_text,
1823
+ engagement_style: d.engagement_style,
1824
+ language: d.language,
1825
+ link_keyword: d.link_keyword,
1826
+ search_topic: sc?.search_topic,
1827
+ matched_project: sc?.matched_project,
1828
+ };
1829
+ });
1830
+ const firstSc = scanById.get(String(args.drafts[0].candidate_id));
1831
+ const project = (candidates.map((c) => c.matched_project).find((p) => !!p) ||
1832
+ firstSc?.matched_project ||
1833
+ "default");
1834
+ const plan = { candidates };
1835
+ writePlan(args.batch_id, plan);
1836
+ // Bake link targets into the plan (sub-second at TWITTER_PAGE_GEN_RATE=0), the
1837
+ // same prep step DRAFT_ONLY does before handing off. Best-effort: posting
1838
+ // falls back to the plain project URL per-candidate if this is skipped.
1839
+ try {
1840
+ await runPython("scripts/twitter_gen_links.py", ["--plan", planPath(args.batch_id)], {
1841
+ timeoutMs: 120_000,
1842
+ env: { TWITTER_PAGE_GEN_RATE: "0", SAPS_REPO_DIR: repoDir(), PATH: pipelinePath() },
1843
+ });
1844
+ }
1845
+ catch {
1846
+ /* best effort — plan still posts with a plain-URL fallback */
1847
+ }
1848
+ const finalPlan = readPlan(args.batch_id) ?? plan;
1849
+ const count = (finalPlan.candidates || []).length;
1850
+ // Surface to the menu-bar review cards (same contract draft_cycle uses).
1851
+ writeReviewRequest({
1852
+ batch_id: args.batch_id,
1853
+ project,
1854
+ count,
1855
+ plan_path: planPath(args.batch_id),
1856
+ created_at: new Date().toISOString(),
1857
+ });
1858
+ return textContent(`${count} draft(s) queued for review (batch ${args.batch_id}). They're now in the menu-bar ` +
1859
+ `approval cards and the table below; nothing posts until approved.\n\n` +
1860
+ renderDraftsTable(finalPlan) +
1861
+ `\n\nTo post the approved ones: the user approves in the menu bar, or call post_drafts with the ` +
1862
+ `numbers to post.`);
1863
+ });
1639
1864
  appTool("dashboard", {
1640
1865
  title: "Social Autoposter dashboard",
1641
1866
  description: "Render the Social Autoposter dashboard in chat: a visual surface showing project setup, X " +
package/mcp/dist/repo.js CHANGED
@@ -151,3 +151,8 @@ export function latestBatchId() {
151
151
  .sort((a, b) => b.mtime - a.mtime);
152
152
  return matches.length ? matches[0].batchId : null;
153
153
  }
154
+ // Hardcoded under TMP_DIR to mirror the cycle script's SCAN_ONLY writer (same
155
+ // coupling planPath has: run-twitter-cycle.sh writes the file to /tmp directly).
156
+ export function scanResultPath(batchId) {
157
+ return path.join(TMP_DIR, `saps_scan_candidates_${batchId}.json`);
158
+ }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.70",
3
- "installedAt": "2026-06-20T03:05:13.835Z"
2
+ "version": "1.6.71",
3
+ "installedAt": "2026-06-20T16:29:20.789Z"
4
4
  }
@@ -95,9 +95,8 @@ class S4LMenuBar(rumps.App):
95
95
  self._sig = None # last rendered state signature; skip rebuild if unchanged
96
96
  self._review_active = False # a review-card sequence is on screen
97
97
  self._reviewed_batches = set() # batch_ids already handled this run
98
- self._posting = False # a post is in flight (drives the title spinner)
99
98
  self._spin_i = 0
100
- self._spinner = None # rumps.Timer animating the title while posting
99
+ self._spinner = None # fast rumps.Timer animating the title while busy
101
100
  # Reliable self-check of our own Accessibility (TCC) grant — this is the
102
101
  # faithful reading (our launchd process identity, not a parent's). Logged
103
102
  # so menubar.err.log records whether keystroke posting will work.
@@ -107,6 +106,11 @@ class S4LMenuBar(rumps.App):
107
106
  sys.stderr.flush()
108
107
  self._timer = rumps.Timer(self._tick, POLL_SECONDS)
109
108
  self._timer.start()
109
+ # Light 1s poll for server activity (scanning/drafting/posting/…); it
110
+ # spins up the fast title-spinner on demand. Idle cost is one tiny file
111
+ # read per second.
112
+ self._act_poll = rumps.Timer(self._poll_activity, 1.0)
113
+ self._act_poll.start()
110
114
  self._tick(None)
111
115
 
112
116
  # ---- side effects -----------------------------------------------------
@@ -194,15 +198,24 @@ class S4LMenuBar(rumps.App):
194
198
  else:
195
199
  self._notify("S4L", "Open Claude Desktop to change autopilot.")
196
200
 
197
- # ---- posting spinner --------------------------------------------------
198
- # Runs on the main thread (rumps timer). While a post is in flight it cycles
199
- # the title; once the background post clears self._posting it restores the
200
- # normal title and stops itself. Cross-thread safe: the worker only flips the
201
- # bool, never touches AppKit.
201
+ # ---- activity spinner -------------------------------------------------
202
+ # The server writes activity.json while a tool runs (scanning/drafting/
203
+ # posting/…). _poll_activity (1s) starts the fast spinner; _spin (0.12s)
204
+ # animates the title with the label and stops itself when activity clears.
205
+ # Both run on the main thread (rumps timers).
206
+ def _poll_activity(self, _):
207
+ act = st.read_activity()
208
+ if act and act.get("label") and self._spinner is None:
209
+ self._spin_i = 0
210
+ self._spinner = rumps.Timer(self._spin, 0.12)
211
+ self._spinner.start()
212
+
202
213
  def _spin(self, _):
203
- if self._posting:
214
+ act = st.read_activity()
215
+ label = act.get("label") if act else None
216
+ if label:
204
217
  self._spin_i = (self._spin_i + 1) % len(SPINNER)
205
- self.title = f"S4L posting {SPINNER[self._spin_i]}"
218
+ self.title = f"S4L {label} {SPINNER[self._spin_i]}"
206
219
  return
207
220
  try:
208
221
  if self._spinner is not None:
@@ -215,9 +228,9 @@ class S4LMenuBar(rumps.App):
215
228
 
216
229
  # ---- tick: read state, set title, (re)build menu ----------------------
217
230
  def _tick(self, _):
218
- # While posting, the spinner owns the UI; don't fight it or start a new
219
- # review mid-post.
220
- if self._posting:
231
+ # While a tool is running, the spinner owns the title/menu; don't fight it
232
+ # or start a review mid-run.
233
+ if self._spinner is not None:
221
234
  return
222
235
  snap = st.snapshot()
223
236
  ob = snap.get("onboarding") or st.read_onboarding()
@@ -317,12 +330,8 @@ class S4LMenuBar(rumps.App):
317
330
  return
318
331
  self._notify("S4L", f"Posting {len(approved)} draft(s)…")
319
332
 
320
- # Animate the menu-bar title until the post finishes.
321
- self._posting = True
322
- self._spin_i = 0
323
- self._spinner = rumps.Timer(self._spin, 0.12)
324
- self._spinner.start()
325
-
333
+ # The server's post_drafts writes "posting" activity, so the activity
334
+ # spinner shows automatically while this runs — no local spinner needed.
326
335
  def work():
327
336
  res = st.post_drafts(batch, post=post_nums, edits=edits)
328
337
  if res is None:
@@ -336,7 +345,6 @@ class S4LMenuBar(rumps.App):
336
345
  f"Posted {posted if posted is not None else len(approved)} draft(s).",
337
346
  )
338
347
  self._review_active = False
339
- self._posting = False # _spin (main thread) restores the title + stops
340
348
 
341
349
  threading.Thread(target=work, daemon=True).start()
342
350
 
@@ -314,6 +314,13 @@ def request_accessibility() -> bool:
314
314
  # produced), present the cards, then post the approved subset via the loopback
315
315
  # post_drafts tool. The chat-table review still works in parallel; both surfaces
316
316
  # de-dup on the plan's per-candidate `posted` flag.
317
+ def read_activity():
318
+ """What the server is doing right now: {state, label} or None when idle.
319
+ Written by long-running tools (scanning/drafting/posting/…); drives the
320
+ menu-bar loading spinner."""
321
+ return read_json("activity.json")
322
+
323
+
317
324
  def read_review_request():
318
325
  return read_json("review-request.json")
319
326
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.70",
3
+ "version": "1.6.71",
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"
@@ -9,8 +9,8 @@ an empty schema. The cycle preflight calls this to heal that automatically:
9
9
  1. Attach to the harness Chrome (TWITTER_CDP_URL, default 127.0.0.1:9555 —
10
10
  the Mac harness port; AppMaker VMs override it to :9222 via the env file).
11
11
  2. Navigate to x.com/home; if it redirects to /login, the session is gone.
12
- 3. Load cookies, mirror-first: the local 0600 mirror written on every connect
13
- (keychain-independent), then the server store for machines with a handle.
12
+ 3. Load cookies from the local 0600 mirror written on every connect
13
+ (keychain-independent).
14
14
  4. Inject them via CDP Network.setCookies and reload.
15
15
  5. Verify we land on /home (logged in).
16
16
 
@@ -29,11 +29,10 @@ import time
29
29
  import urllib.request
30
30
 
31
31
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
32
- from http_api import api_get # noqa: E402
33
- from twitter_account import resolve_handle # noqa: E402
34
32
 
35
- # Local 0600 cookie mirror — the keychain-independent restore source for
36
- # persistent machines (Gap B). Tried before the server store. Stdlib-only;
33
+ # Local 0600 cookie mirror — the keychain-independent restore source (Gap B).
34
+ # It is the ONLY cookie source; the VM-era server store
35
+ # (/api/v1/twitter/session-cookies) was removed 2026-06-17. Stdlib-only;
37
36
  # guarded so a path quirk never breaks the cycle preflight.
38
37
  try:
39
38
  import twitter_cookie_mirror # noqa: E402
@@ -125,10 +124,10 @@ def _inject(send, cookies) -> int:
125
124
 
126
125
 
127
126
  def _stored_cookies():
128
- """Return (cookies, source). Tries the LOCAL mirror first it's the only
129
- durable source on a persistent machine, where the server store is skipped
130
- for lack of a social_accounts row then falls back to the server store
131
- (the durable source on hourly-reseeded AppMaker VMs)."""
127
+ """Return (cookies, source) from the LOCAL 0600 mirror, or ([], None).
128
+
129
+ The mirror is the only cookie source; the VM-era server store
130
+ (/api/v1/twitter/session-cookies) was removed 2026-06-17."""
132
131
  if twitter_cookie_mirror is not None:
133
132
  try:
134
133
  mirrored = twitter_cookie_mirror.load_cookies()
@@ -136,20 +135,6 @@ def _stored_cookies():
136
135
  mirrored = []
137
136
  if mirrored:
138
137
  return mirrored, f"local mirror ({twitter_cookie_mirror.MIRROR_PATH.name})"
139
-
140
- handle = None
141
- try:
142
- handle = resolve_handle()
143
- except Exception:
144
- handle = None
145
- if handle:
146
- try:
147
- resp = api_get("/api/v1/twitter/session-cookies", query={"handle": handle})
148
- cookies = ((resp or {}).get("data") or {}).get("cookies") or []
149
- if cookies:
150
- return cookies, f"server store (@{handle})"
151
- except Exception as e:
152
- print(f"restore_twitter_session: server store fetch failed ({e})", file=sys.stderr)
153
138
  return [], None
154
139
 
155
140
 
@@ -167,8 +152,8 @@ def main():
167
152
 
168
153
  cookies, source = _stored_cookies()
169
154
  if not cookies:
170
- print("restore_twitter_session: no stored cookies (local mirror empty + no "
171
- "server store); manual connect_x required", file=sys.stderr)
155
+ print("restore_twitter_session: no stored cookies (local mirror empty); "
156
+ "manual connect_x required", file=sys.stderr)
172
157
  return 1
173
158
 
174
159
  print(f"restore_twitter_session: logged out, restoring from {source}...")
@@ -60,15 +60,13 @@ except ImportError:
60
60
  "websocket-client not installed (needed for CDP). pip install websocket-client"
61
61
  )
62
62
 
63
- # Optional server-side session-cookie store (best-effort). Lets connect_x persist
64
- # the validated X cookies so restore_twitter_session.py can auto-re-inject them
65
- # after any logout. Guarded so a missing dep or offline API never breaks setup.
63
+ # Live-handle resolver (best-effort). Lets connect_x record the real logged-in
64
+ # @handle alongside the locally-mirrored cookies. Guarded so a missing dep never
65
+ # breaks setup.
66
66
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
67
67
  try:
68
- from http_api import api_post # noqa: E402
69
68
  from twitter_account import resolve_handle # noqa: E402
70
69
  except Exception:
71
- api_post = None
72
70
  resolve_handle = None
73
71
 
74
72
  # Local 0600 cookie mirror — the keychain-independent durability layer (Gap B).
@@ -386,16 +384,13 @@ def _write_handle_to_config(handle: "str | None") -> bool:
386
384
 
387
385
  def _persist_session() -> None:
388
386
  """Persist the validated live X session for auto-restore after ANY logout
389
- (hard kill, crash, keychain re-lock wiping Chrome's Cookies DB, or AppMaker
390
- VM reseed). One CDP attach feeds two sinks:
387
+ (hard kill, crash, or a keychain re-lock wiping Chrome's Cookies DB).
391
388
 
392
- 1. LOCAL 0600 mirror (twitter_cookie_mirror) ALWAYS written. This is the
393
- keychain-independent durability layer that fixes Gap B on a persistent
394
- machine: restore_twitter_session.py re-injects from it on the next cycle
395
- preflight even after Chrome wiped its own encrypted store.
396
- 2. Server-side session store (POST /api/v1/twitter/session-cookies) —
397
- best-effort. Enables VM auto-restore where the profile is reseeded
398
- hourly. No-op on a persistent machine with no social_accounts row.
389
+ Writes the validated x.com/twitter.com cookies to the LOCAL 0600 mirror
390
+ (twitter_cookie_mirror) — the keychain-independent durability layer that
391
+ fixes Gap B on a persistent machine: restore_twitter_session.py re-injects
392
+ from it on the next cycle preflight even after Chrome wiped its own
393
+ encrypted store.
399
394
 
400
395
  Non-fatal end-to-end: the local session is already valid; this only enables
401
396
  future auto-recovery, so nothing here may abort connect_x."""
@@ -437,7 +432,9 @@ def _persist_session() -> None:
437
432
  except Exception:
438
433
  handle = None
439
434
 
440
- # 1. Local mirror — always, keychain-independent.
435
+ # Local mirror — keychain-independent durability. This is the only cookie
436
+ # store; the VM-era server store (/api/v1/twitter/session-cookies) was
437
+ # removed 2026-06-17 when we stopped running AppMaker VMs.
441
438
  if twitter_cookie_mirror is not None:
442
439
  try:
443
440
  n = twitter_cookie_mirror.save_cookies(cookies, handle=handle)
@@ -447,18 +444,6 @@ def _persist_session() -> None:
447
444
  except Exception as e:
448
445
  print(f"setup_twitter_auth: local mirror save skipped ({e})", file=sys.stderr)
449
446
 
450
- # 2. Server store — best-effort, only when a handle resolves.
451
- if api_post is not None and handle:
452
- try:
453
- api_post("/api/v1/twitter/session-cookies", {"handle": handle, "cookies": cookies})
454
- print(f"setup_twitter_auth: saved {len(cookies)} session cookies for @{handle} "
455
- "(server auto-restore enabled)", file=sys.stderr)
456
- # api_post raises SystemExit (BaseException, NOT Exception) on a 4xx/5xx —
457
- # e.g. "no social_accounts row" on a persistent machine that never
458
- # registered this handle. Best-effort: must never abort connect_x.
459
- except (Exception, SystemExit) as e:
460
- print(f"setup_twitter_auth: session-store save skipped ({e})", file=sys.stderr)
461
-
462
447
 
463
448
  def _show_window_and_open_login() -> bool:
464
449
  """Make the managed Chrome window VISIBLE + focused and land it on the X login
@@ -203,9 +203,20 @@ log "Length-control experiment concluded 2026-06-04; winner=control; LENGTH_ARM
203
203
  # spend the sysctl read for the next check.
204
204
  source "$REPO_DIR/scripts/preflight.sh"
205
205
  SA_PREFLIGHT_SCRIPT="run-twitter-cycle"
206
- preflight_skip_if_claude_blocked
207
- preflight_skip_if_jetsam_pressure
208
- preflight_acquire_slot_or_skip "twitter-cycle" 1
206
+ if [ "${SCAN_ONLY:-0}" = "1" ]; then
207
+ # SCAN_ONLY (the Desktop-session autopilot's scan step) gets its OWN slot pool
208
+ # so it is NOT blocked by a live launchd autopilot cycle; the two then serialize
209
+ # on the twitter-browser lock (acquired in Phase 1) instead of being mutually
210
+ # exclusive. It also SKIPS the claude-blocked gate: SCAN_ONLY drives no
211
+ # `claude -p`, so a prior claude cap must not suppress a pure scan.
212
+ SA_PREFLIGHT_SCRIPT="run-twitter-cycle-scan"
213
+ preflight_skip_if_jetsam_pressure
214
+ preflight_acquire_slot_or_skip "twitter-scan" 1
215
+ else
216
+ preflight_skip_if_claude_blocked
217
+ preflight_skip_if_jetsam_pressure
218
+ preflight_acquire_slot_or_skip "twitter-cycle" 1
219
+ fi
209
220
 
210
221
  # Source lock helpers (functions only, no lock acquired here). Phase 0 + the
211
222
  # project/queries setup below run lock-free against DB and config files;
@@ -1520,6 +1531,57 @@ if [ -z "$CANDIDATES" ]; then
1520
1531
  exit 0
1521
1532
  fi
1522
1533
 
1534
+ # --- SCAN_ONLY gate: stop after scoring, emit candidates, skip drafting -------
1535
+ # When SCAN_ONLY=1 the cycle runs scan -> score -> top-N select, writes the chosen
1536
+ # candidates as JSON, and STOPS before the claude drafting step. The MCP
1537
+ # scan_candidates tool reads this so a Claude Desktop scheduled-task session can do
1538
+ # the drafting ITSELF (on the user's plan, no `claude -p`) and hand the drafts back
1539
+ # via submit_drafts. Candidates stay 'pending' (drafted+posted via submit_drafts ->
1540
+ # post_drafts, or salvaged by a later cycle). The browser lock was already released
1541
+ # at the Phase 1 handoff, so this exits clean via the EXIT trap. NO current caller
1542
+ # sets SCAN_ONLY, so the autopilot/draft_cycle paths are byte-for-byte unchanged.
1543
+ if [ "${SCAN_ONLY:-0}" = "1" ]; then
1544
+ SCAN_FILE="/tmp/saps_scan_candidates_${BATCH_ID}.json"
1545
+ # $CANDIDATES is the same pipe-separated top-N the drafting step consumes (cols
1546
+ # documented in twitter_cycle_helper.py:cmd_candidates; tweet_text/draft fields
1547
+ # are pipe+newline sanitized there, so a field split is safe). Batch id + out
1548
+ # path travel via env so the single-quoted python needs no shell interpolation.
1549
+ printf '%s\n' "$CANDIDATES" | SAPS_SCAN_FILE="$SCAN_FILE" SAPS_SCAN_BATCH="$BATCH_ID" python3 -c '
1550
+ import json, os, sys
1551
+ def _i(x):
1552
+ try:
1553
+ return int(float(x or 0))
1554
+ except Exception:
1555
+ return 0
1556
+ def _f(x):
1557
+ try:
1558
+ return float(x or 0)
1559
+ except Exception:
1560
+ return 0.0
1561
+ out = []
1562
+ for line in sys.stdin:
1563
+ line = line.rstrip("\n")
1564
+ if not line.strip():
1565
+ continue
1566
+ p = line.split("|")
1567
+ if len(p) < 14 or not p[0].isdigit():
1568
+ continue
1569
+ out.append({
1570
+ "id": int(p[0]), "tweet_url": p[1], "author_handle": p[2], "tweet_text": p[3],
1571
+ "virality_score": _f(p[4]), "delta_score": _f(p[5]), "matched_project": p[6],
1572
+ "search_topic": p[7], "likes": _i(p[8]), "retweets": _i(p[9]), "replies": _i(p[10]),
1573
+ "views": _i(p[11]), "author_followers": _i(p[12]), "age_hours": _f(p[13]),
1574
+ "existing_draft": p[14] if len(p) > 14 else "", "existing_draft_style": p[15] if len(p) > 15 else "",
1575
+ })
1576
+ json.dump({"batch_id": os.environ["SAPS_SCAN_BATCH"], "candidates": out}, open(os.environ["SAPS_SCAN_FILE"], "w"))
1577
+ ' 2>/dev/null || printf '{"batch_id": "%s", "candidates": []}' "$BATCH_ID" > "$SCAN_FILE"
1578
+ SCAN_N=$(python3 -c "import json; print(len(json.load(open('$SCAN_FILE')).get('candidates') or []))" 2>/dev/null || echo 0)
1579
+ log "SCAN_ONLY=1: $SCAN_N candidate(s) scored and written to $SCAN_FILE. Stopping before drafting (agent drafts next)."
1580
+ _SA_RUN_SUMMARY_EMITTED=1
1581
+ echo "SCAN_ONLY_RESULT=$SCAN_FILE"
1582
+ exit 0
1583
+ fi
1584
+
1523
1585
  CANDIDATE_COUNT=$(printf '%s\n' "$CANDIDATES" | grep -c '^[0-9]')
1524
1586
  log "Top $CANDIDATE_COUNT candidates by virality_score selected for post review."
1525
1587