social-autoposter 1.6.70 → 1.6.72
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 +0 -120
- package/mcp/dist/index.js +276 -159
- package/mcp/dist/repo.js +5 -0
- package/mcp/dist/telemetry.js +1 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/menubar/s4l_menubar.py +27 -19
- package/mcp/menubar/s4l_state.py +7 -0
- package/package.json +1 -2
- package/scripts/restore_twitter_session.py +11 -26
- package/scripts/sentry_init.py +1 -1
- package/scripts/setup_twitter_auth.py +12 -27
- package/skill/lock.sh +3 -4
- package/skill/run-twitter-cycle.sh +77 -19
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
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// social-autoposter MCP server (X/Twitter rail).
|
|
3
3
|
//
|
|
4
4
|
// Core tools:
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// post_drafts
|
|
5
|
+
// scan_candidates - scan + score X threads, return the top candidates (no draft, no post).
|
|
6
|
+
// submit_drafts - take the replies you drafted and queue them for review (posts nothing).
|
|
7
|
+
// post_drafts - post the drafts the user chose by number from a batch.
|
|
8
8
|
// get_stats - read-only post + engagement stats.
|
|
9
9
|
//
|
|
10
10
|
// THIN wrapper. The pipeline brain (scan, score, drafting prompts, posting)
|
|
@@ -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";
|
|
@@ -152,7 +152,8 @@ const server = new McpServer({
|
|
|
152
152
|
"ONBOARDING IS A TERMINAL GOAL. When the user asks to set up, install, configure, or onboard " +
|
|
153
153
|
"social-autoposter, keep taking the next safe action until the owned runtime is ready, a " +
|
|
154
154
|
"project is fully configured with seeded search topics, X is connected with its real handle, " +
|
|
155
|
-
"and
|
|
155
|
+
"and the draft path (`scan_candidates` -> draft -> `submit_drafts`) has verified the pipeline " +
|
|
156
|
+
"without posting. Do not ask whether to inspect " +
|
|
156
157
|
"status, install or repair owned dependencies, choose an auto-detected browser profile, connect " +
|
|
157
158
|
"X, scan the profile, research the product website, save conservative inferred fields, seed " +
|
|
158
159
|
"topics, retry a recoverable failure, or run draft-only verification. Do those things. The " +
|
|
@@ -162,25 +163,28 @@ const server = new McpServer({
|
|
|
162
163
|
"when no product can be identified from config, context, the X profile, or public research, " +
|
|
163
164
|
"or pause for an unavoidable interactive login or OS prompt. Never post drafts or enable " +
|
|
164
165
|
"autopilot during setup unless the user explicitly requested that. When setup reaches done — " +
|
|
165
|
-
"runtime ready, a project configured with seeded topics, X connected, and
|
|
166
|
-
"verified — call the `dashboard` tool so the user
|
|
166
|
+
"runtime ready, a project configured with seeded topics, X connected, and the draft path " +
|
|
167
|
+
"(scan_candidates -> draft -> submit_drafts) verified — call the `dashboard` tool so the user " +
|
|
168
|
+
"sees the finished setup rendered, then give " +
|
|
167
169
|
"the completion summary.\n\n" +
|
|
168
170
|
"BE PROACTIVE ABOUT MARKETING MOMENTS. Whenever the user ships, finishes, merges, or releases " +
|
|
169
171
|
"something worth talking about in this session (a new feature, a launch, a long-awaited fix, a " +
|
|
170
|
-
"milestone), don't wait to be asked: offer to tell the world by running
|
|
171
|
-
"
|
|
172
|
+
"milestone), don't wait to be asked: offer to tell the world by running a draft cycle " +
|
|
173
|
+
"(`scan_candidates` -> draft the replies -> `submit_drafts`) about it. A feature nobody hears " +
|
|
174
|
+
"about is wasted distribution. Use judgment, suggest it for " +
|
|
172
175
|
"genuinely shareable moments, not routine refactors, chores, or internal cleanup.\n\n" +
|
|
173
176
|
"STAY CURRENT. At the start of a session, and again right after you help the user ship/merge/" +
|
|
174
177
|
"release, call the `runtime` tool (action:'version'). If `update_available` is true, tell the " +
|
|
175
178
|
"user and offer to run `runtime` with action:'update'. The `project_config` tool's status also " +
|
|
176
179
|
"surfaces `update_available` and an `update_hint`.\n\n" +
|
|
177
|
-
"TYPICAL FLOW: `project_config` (configure OR edit the project, and connect X) -> `
|
|
178
|
-
"(
|
|
180
|
+
"TYPICAL FLOW: `project_config` (configure OR edit the project, and connect X) -> `scan_candidates` " +
|
|
181
|
+
"(get the top threads to reply to) -> draft the replies yourself -> `submit_drafts` (queue them " +
|
|
182
|
+
"for review; nothing posts until the user approves) -> `post_drafts` (post the approved ones) -> " +
|
|
179
183
|
"`get_stats` (see performance). Run `project_config` first; the other tools refuse until a " +
|
|
180
184
|
"project is fully configured. To change anything about a project later, call `project_config` " +
|
|
181
185
|
"again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
|
|
182
186
|
"RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
|
|
183
|
-
"(`
|
|
187
|
+
"(`scan_candidates`, `submit_drafts`, `post_drafts`, `get_stats`), end your turn by " +
|
|
184
188
|
"calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
|
|
185
189
|
"`dashboard` after pure Q&A, config explanations, or status-only checks that changed nothing.",
|
|
186
190
|
});
|
|
@@ -190,9 +194,31 @@ const baseRegisterTool = server.registerTool.bind(server);
|
|
|
190
194
|
// same input-schema -> callback-arg inference it had before; the body is `any`
|
|
191
195
|
// and just additionally stashes the callback by name. `appTool` drops the
|
|
192
196
|
// leading `server` arg of registerAppTool (its callback takes no typed args).
|
|
197
|
+
// Tools that take a while: writing activity.json around them makes the menu bar
|
|
198
|
+
// show a spinner + label while they run (either invocation path). draft_cycle is
|
|
199
|
+
// NOT here — it writes finer scanning/drafting phases itself (see produceDrafts).
|
|
200
|
+
const TOOL_ACTIVITY = {
|
|
201
|
+
post_drafts: "posting",
|
|
202
|
+
get_stats: "loading stats",
|
|
203
|
+
};
|
|
204
|
+
function withActivity(name, cb) {
|
|
205
|
+
const label = TOOL_ACTIVITY[name];
|
|
206
|
+
if (!label)
|
|
207
|
+
return cb;
|
|
208
|
+
return async (args, extra) => {
|
|
209
|
+
writeActivity("working", label);
|
|
210
|
+
try {
|
|
211
|
+
return await cb(args, extra);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
clearActivity();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
193
218
|
const tool = ((name, config, cb) => {
|
|
194
|
-
|
|
195
|
-
|
|
219
|
+
const h = withActivity(name, cb);
|
|
220
|
+
TOOL_HANDLERS[name] = h;
|
|
221
|
+
return baseRegisterTool(name, config, h);
|
|
196
222
|
});
|
|
197
223
|
const appTool = ((name, config, cb) => {
|
|
198
224
|
// Wrap every tool handler so any thrown error is reported to Sentry. Single
|
|
@@ -208,8 +234,9 @@ const appTool = ((name, config, cb) => {
|
|
|
208
234
|
throw e;
|
|
209
235
|
}
|
|
210
236
|
});
|
|
211
|
-
|
|
212
|
-
|
|
237
|
+
const h = withActivity(name, wrapped);
|
|
238
|
+
TOOL_HANDLERS[name] = h;
|
|
239
|
+
return registerAppTool(server, name, config, h);
|
|
213
240
|
});
|
|
214
241
|
function jsonContent(obj) {
|
|
215
242
|
return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
|
|
@@ -231,28 +258,28 @@ function blockedReasonMessage(reason) {
|
|
|
231
258
|
"couldn't run. (It DID find and rank threads, it just couldn't draft replies.) This " +
|
|
232
259
|
"CLI uses its own login, separate from Claude Desktop. To fix it, open a terminal and run:\n\n" +
|
|
233
260
|
" claude\n\n" +
|
|
234
|
-
"then `/login` inside it (or run `claude setup-token`). Once it's logged in, run
|
|
261
|
+
"then `/login` inside it (or run `claude setup-token`). Once it's logged in, run scan_candidates again.");
|
|
235
262
|
case "monthly_limit":
|
|
236
263
|
case "daily_limit":
|
|
237
264
|
case "rate_limit_5h":
|
|
238
265
|
return (`The drafting step hit an Anthropic usage limit (${reason}), so no replies were drafted. ` +
|
|
239
|
-
"Wait for the limit to reset, then run
|
|
266
|
+
"Wait for the limit to reset, then run scan_candidates again.");
|
|
240
267
|
case "no_search_topics":
|
|
241
268
|
return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
|
|
242
269
|
"DB (project_search_topics) and are seeded from your project's `search_topics` when you " +
|
|
243
270
|
"configure it. Re-run the `project_config` tool for this project with a `search_topics` list " +
|
|
244
271
|
"(comma-separated keywords/phrases your buyers tweet about); it seeds them automatically, then " +
|
|
245
|
-
"run
|
|
272
|
+
"run scan_candidates again.");
|
|
246
273
|
case "topics_api_unreachable":
|
|
247
274
|
return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
|
|
248
|
-
"before scanning. This is usually a transient backend/network issue. Try
|
|
275
|
+
"before scanning. This is usually a transient backend/network issue. Try scan_candidates again in a " +
|
|
249
276
|
"moment; if it persists, check connectivity to the autoposter backend.");
|
|
250
277
|
case "credit_balance":
|
|
251
278
|
return ("The drafting step failed because the Anthropic account is out of credits. " +
|
|
252
|
-
"Add credits, then run
|
|
279
|
+
"Add credits, then run scan_candidates again.");
|
|
253
280
|
default:
|
|
254
281
|
return (`The drafting step failed (${reason}) and produced no drafts. ` +
|
|
255
|
-
"Check skill/logs/twitter-cycle-*.log on this machine for details, then run
|
|
282
|
+
"Check skill/logs/twitter-cycle-*.log on this machine for details, then run scan_candidates again.");
|
|
256
283
|
}
|
|
257
284
|
}
|
|
258
285
|
// Turn a raw run-twitter-cycle.sh stdout line into a short, user-facing
|
|
@@ -371,6 +398,9 @@ async function produceDrafts(project, onProgress) {
|
|
|
371
398
|
}
|
|
372
399
|
appendLog(`\n===== draft_cycle start ${new Date().toISOString()} ` +
|
|
373
400
|
`project=${project ?? "(default)"} =====\n`);
|
|
401
|
+
// Menu-bar status: scanning first, then drafting once the prep phase begins
|
|
402
|
+
// (switched in onLine below). Cleared before every return.
|
|
403
|
+
writeActivity("scanning", "scanning X");
|
|
374
404
|
const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
|
|
375
405
|
env,
|
|
376
406
|
timeoutMs: 900_000, // scan+draft can take several minutes
|
|
@@ -386,6 +416,8 @@ async function produceDrafts(project, onProgress) {
|
|
|
386
416
|
appendLog(`${t}\n`);
|
|
387
417
|
console.error(`[draft_cycle] ${t}`);
|
|
388
418
|
}
|
|
419
|
+
if (/Phase 2b-prep/.test(t))
|
|
420
|
+
writeActivity("drafting", "drafting replies");
|
|
389
421
|
if (!onProgress)
|
|
390
422
|
return;
|
|
391
423
|
const msg = cycleProgressMessage(t);
|
|
@@ -399,13 +431,16 @@ async function produceDrafts(project, onProgress) {
|
|
|
399
431
|
appendLog(`===== draft_cycle end ${new Date().toISOString()} exit=${res.code} =====\n`);
|
|
400
432
|
// Prefer the explicit marker; fall back to the newest plan file on disk.
|
|
401
433
|
const marker = /DRAFT_ONLY_PLAN=\/tmp\/twitter_cycle_plan_(.+)\.json/.exec(res.stdout + "\n" + res.stderr);
|
|
402
|
-
if (marker && marker[1])
|
|
434
|
+
if (marker && marker[1]) {
|
|
435
|
+
clearActivity();
|
|
403
436
|
return { batchId: marker[1] };
|
|
437
|
+
}
|
|
404
438
|
// A real prep-step failure (e.g. the background claude CLI isn't logged in)
|
|
405
439
|
// emits DRAFT_ONLY_BLOCKED=<reason>. Surface that instead of silently falling
|
|
406
440
|
// back to a stale/empty batch and mis-reporting "no fresh candidates".
|
|
407
441
|
const blockedMarker = /DRAFT_ONLY_BLOCKED=([a-z0-9_]+)/.exec(res.stdout + "\n" + res.stderr);
|
|
408
442
|
if (blockedMarker && blockedMarker[1]) {
|
|
443
|
+
clearActivity();
|
|
409
444
|
return { batchId: null, blocked: blockedReasonMessage(blockedMarker[1]) };
|
|
410
445
|
}
|
|
411
446
|
// No `DRAFT_ONLY_PLAN=` marker from THIS run => this run produced no drafts.
|
|
@@ -413,6 +448,7 @@ async function produceDrafts(project, onProgress) {
|
|
|
413
448
|
// that's a *previous* run's batch, so a 5-second empty cycle would echo an old
|
|
414
449
|
// 7-draft batch and report phantom success. Report 0 drafts honestly, with the
|
|
415
450
|
// pipeline's own reason (e.g. cold-start project with no seeded queries).
|
|
451
|
+
clearActivity();
|
|
416
452
|
return {
|
|
417
453
|
batchId: null,
|
|
418
454
|
blocked: `This run produced no drafts (exit ${res.code}). The scan found no fresh ` +
|
|
@@ -615,7 +651,7 @@ tool("project_config", {
|
|
|
615
651
|
"Call with status:true (or no name) to list every configured project, its remaining fields, AND " +
|
|
616
652
|
"whether X is connected. Use config, conversation context, profile_scan, and website research " +
|
|
617
653
|
"before asking for fields. Ask only if no product can be identified or an interactive login is " +
|
|
618
|
-
"unavoidable. The
|
|
654
|
+
"unavoidable. The scan_candidates and get_stats tools refuse to run until a project is " +
|
|
619
655
|
"fully set up.",
|
|
620
656
|
inputSchema: {
|
|
621
657
|
status: z.boolean().optional(),
|
|
@@ -779,7 +815,7 @@ tool("project_config", {
|
|
|
779
815
|
next_step: r.connected
|
|
780
816
|
? "X is connected. Next, run project_config action:'profile_scan' to read this account's bio + recent " +
|
|
781
817
|
"posts + replies and draft the project's voice/icp/search_topics in the user's own register " +
|
|
782
|
-
"before saving. Then run
|
|
818
|
+
"before saving. Then run a draft cycle (scan_candidates -> draft -> submit_drafts) once the project is fully set up."
|
|
783
819
|
: r.state === "needs_login"
|
|
784
820
|
? "The user must finish signing in to x.com in the Chrome window that just opened. Tell " +
|
|
785
821
|
"them that single required action, then call project_config action:'connect_x', confirm:true again."
|
|
@@ -886,7 +922,7 @@ tool("project_config", {
|
|
|
886
922
|
(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.")
|
|
887
923
|
: projects.every((p) => p.ready)
|
|
888
924
|
? (x.connected
|
|
889
|
-
? "All configured projects are ready and X is connected. Run
|
|
925
|
+
? "All configured projects are ready and X is connected. Run scan_candidates, draft a reply or two, and submit_drafts now to verify end to end without posting. After it verifies, call the `dashboard` tool so the user sees the finished setup."
|
|
890
926
|
: "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
|
|
891
927
|
"x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
|
|
892
928
|
: "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." +
|
|
@@ -928,13 +964,13 @@ tool("project_config", {
|
|
|
928
964
|
topic_count: topicCount,
|
|
929
965
|
});
|
|
930
966
|
seedNote = m
|
|
931
|
-
? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so
|
|
932
|
-
: " Seeded search topics into the DB so
|
|
967
|
+
? ` Seeded ${m[1]} search topic(s) into the DB (new: ${m[2]}, updated: ${m[3]}), so scan_candidates has a topic universe to work with.`
|
|
968
|
+
: " Seeded search topics into the DB so scan_candidates has a topic universe to work with.";
|
|
933
969
|
}
|
|
934
970
|
else {
|
|
935
971
|
const tail = (seed.stderr || seed.stdout).trim().split("\n").slice(-1)[0] || "unknown error";
|
|
936
972
|
blockOnboardingMilestone("topics_seeded", "topic_seed_failed", tail, { exit_code: seed.code });
|
|
937
|
-
seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}.
|
|
973
|
+
seedNote = ` (Heads up: couldn't seed search topics into the DB yet — ${tail}. scan_candidates will tell you clearly if topics are missing.)`;
|
|
938
974
|
}
|
|
939
975
|
// Cold-start QUERY supply: fan the seeded topics out into >=30 real X
|
|
940
976
|
// search queries (project_search_queries) so the deterministic Phase 1
|
|
@@ -998,7 +1034,7 @@ tool("project_config", {
|
|
|
998
1034
|
note: (result.ready
|
|
999
1035
|
? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
|
|
1000
1036
|
`detect sources, warn about keychain prompts, and call project_config with ` +
|
|
1001
|
-
`action:'connect_x', confirm:true immediately. Once X is connected, run
|
|
1037
|
+
`action:'connect_x', confirm:true immediately. Once X is connected, run scan_candidates -> submit_drafts to` +
|
|
1002
1038
|
`verify without posting. Do not enable autopilot unless explicitly requested.`
|
|
1003
1039
|
: `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
|
|
1004
1040
|
`First derive those fields from existing context, profile_scan, and website research, then ` +
|
|
@@ -1010,145 +1046,29 @@ tool("project_config", {
|
|
|
1010
1046
|
return textContent(`Setup failed: ${e.message}`);
|
|
1011
1047
|
}
|
|
1012
1048
|
});
|
|
1013
|
-
// ---- draft_cycle:
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
"to post and which to rewrite, then call `post_drafts` with their decision and the " +
|
|
1022
|
-
"returned batch_id. The table MUST show, per draft: the thread being replied to " +
|
|
1023
|
-
"(thread_text), the draft reply, and the link target (link_url) when present; never " +
|
|
1024
|
-
"drop those columns. Flow: discover -> draft -> review in chat -> post_drafts. " +
|
|
1025
|
-
"After returning the table, call the `dashboard` tool so the user sees the updated state.",
|
|
1026
|
-
inputSchema: {
|
|
1027
|
-
project: z
|
|
1028
|
-
.string()
|
|
1029
|
-
.optional()
|
|
1030
|
-
.describe("Which configured project to draft for. Optional when only one project is set up; required when several are."),
|
|
1031
|
-
},
|
|
1032
|
-
}, async ({ project }, extra) => {
|
|
1033
|
-
const r = resolveProject(project);
|
|
1034
|
-
if (!r.ok)
|
|
1035
|
-
return textContent(r.message);
|
|
1036
|
-
const proj = r.project;
|
|
1037
|
-
recordOnboardingAttempt("draft_verified");
|
|
1038
|
-
// Live progress so the chat doesn't sit on a frozen spinner for minutes.
|
|
1039
|
-
// Two channels, both best-effort (a sink failure must never fail the cycle):
|
|
1040
|
-
// 1. notifications/message — a log line; the host records it (and some
|
|
1041
|
-
// clients show it in a log view). Works with no client opt-in.
|
|
1042
|
-
// 2. notifications/progress — drives the status text under the running
|
|
1043
|
-
// tool. Only valid when the client supplied a progressToken on the
|
|
1044
|
-
// request, so it's guarded on that.
|
|
1045
|
-
const progressToken = extra?._meta?.progressToken;
|
|
1046
|
-
const sendProgress = async (message, step) => {
|
|
1047
|
-
try {
|
|
1048
|
-
await extra.sendNotification({
|
|
1049
|
-
method: "notifications/message",
|
|
1050
|
-
params: { level: "info", logger: "draft_cycle", data: message },
|
|
1051
|
-
});
|
|
1052
|
-
}
|
|
1053
|
-
catch {
|
|
1054
|
-
/* ignore */
|
|
1055
|
-
}
|
|
1056
|
-
if (progressToken !== undefined) {
|
|
1057
|
-
try {
|
|
1058
|
-
await extra.sendNotification({
|
|
1059
|
-
method: "notifications/progress",
|
|
1060
|
-
params: { progressToken, progress: step, message },
|
|
1061
|
-
});
|
|
1062
|
-
}
|
|
1063
|
-
catch {
|
|
1064
|
-
/* ignore */
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
};
|
|
1068
|
-
const drafted = await produceDrafts(proj, (message, step) => {
|
|
1069
|
-
void sendProgress(message, step);
|
|
1070
|
-
});
|
|
1071
|
-
if (drafted.blocked || !drafted.batchId) {
|
|
1072
|
-
blockOnboardingMilestone("draft_verified", "draft_cycle_blocked", drafted.blocked ?? "No drafts produced.", { outcome: "blocked" });
|
|
1073
|
-
return textContent(drafted.blocked ?? "No drafts produced.");
|
|
1074
|
-
}
|
|
1075
|
-
const plan = readPlan(drafted.batchId);
|
|
1076
|
-
if (!plan || !(plan.candidates && plan.candidates.length)) {
|
|
1077
|
-
blockOnboardingMilestone("draft_verified", "draft_batch_empty", `No drafts in batch ${drafted.batchId}.`, { outcome: "empty_batch", draft_count: 0 });
|
|
1078
|
-
return textContent(`No drafts in batch ${drafted.batchId}.`);
|
|
1079
|
-
}
|
|
1080
|
-
const count = plan.candidates.length;
|
|
1081
|
-
completeOnboardingMilestone("draft_verified", {
|
|
1082
|
-
outcome: "review_batch",
|
|
1083
|
-
draft_count: count,
|
|
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
|
-
});
|
|
1094
|
-
const table = renderDraftsTable(plan);
|
|
1095
|
-
const message = `Drafted ${count} ${count === 1 ? "reply" : "replies"} for "${proj}" ` +
|
|
1096
|
-
`(batch ${drafted.batchId}). NOTHING has been posted yet.\n\n` +
|
|
1097
|
-
`${table}\n\n` +
|
|
1098
|
-
`Show this list to the user and ask which to post and which to edit. When you render ` +
|
|
1099
|
-
`the table, ALWAYS include, for every draft: the thread it replies to (in reply to / ` +
|
|
1100
|
-
`thread_text), the draft text, and the link target (link_url) if present. Do NOT drop ` +
|
|
1101
|
-
`the thread or link columns. Note the literal short link is appended at post time and ` +
|
|
1102
|
-
`an A/B gate may omit it, so present link_url as the target, not a guaranteed final URL. ` +
|
|
1103
|
-
`They can reply however is natural, e.g. "post 1, 3 and 5", "edit 2: <new wording>", ` +
|
|
1104
|
-
`"post all", or "skip all". Editing a draft also posts it. Then call the post_drafts ` +
|
|
1105
|
-
`tool with batch_id "${drafted.batchId}" and their decision (post: [numbers], ` +
|
|
1106
|
-
`edits: [{n, text}], or post_all: true). Do not post anything the user didn't ask for.`;
|
|
1107
|
-
return {
|
|
1108
|
-
content: [{ type: "text", text: message }],
|
|
1109
|
-
structuredContent: {
|
|
1110
|
-
batch_id: drafted.batchId,
|
|
1111
|
-
drafted: count,
|
|
1112
|
-
status: "awaiting_decision",
|
|
1113
|
-
onboarding: onboardingSnapshot(),
|
|
1114
|
-
// Include the actual draft text here, not just a count. Some hosts
|
|
1115
|
-
// (e.g. Claude Desktop) surface ONLY structuredContent to the model and
|
|
1116
|
-
// drop the human-readable `content` table — which left the agent saying
|
|
1117
|
-
// "drafted: 2" with no way to show the drafts. Carrying the drafts in
|
|
1118
|
-
// structuredContent makes them available regardless of host behavior.
|
|
1119
|
-
drafts: (plan.candidates || []).map((c, i) => ({
|
|
1120
|
-
n: i + 1,
|
|
1121
|
-
author: c.thread_author,
|
|
1122
|
-
tweet_url: c.candidate_url,
|
|
1123
|
-
// The original tweet being replied to — reviewer context. Hosts that
|
|
1124
|
-
// surface ONLY structuredContent (Claude Desktop, Fazm Code tab) need
|
|
1125
|
-
// this here or the relayed table loses the thread it's responding to.
|
|
1126
|
-
thread_text: c.thread_text,
|
|
1127
|
-
reply_text: c.reply_text,
|
|
1128
|
-
// Target link only. The literal /r/<code> short link is minted at post
|
|
1129
|
-
// time and an A/B gate may omit it; do not pre-mint here.
|
|
1130
|
-
link_url: c.link_url,
|
|
1131
|
-
style: c.engagement_style,
|
|
1132
|
-
language: c.language,
|
|
1133
|
-
})),
|
|
1134
|
-
},
|
|
1135
|
-
};
|
|
1136
|
-
});
|
|
1049
|
+
// ---- draft_cycle: DEPRECATED 2026-06-20 (registration removed) -------------
|
|
1050
|
+
// Replaced by the scan_candidates -> (agent drafts) -> submit_drafts flow, so the
|
|
1051
|
+
// AI drafting now runs in the calling session (on the user's plan) instead of a
|
|
1052
|
+
// spawned `claude -p`. post_drafts (below) still posts the approved subset, and
|
|
1053
|
+
// submit_drafts completes the onboarding "draft_verified" milestone that this
|
|
1054
|
+
// tool used to. The underlying pipeline (run-twitter-cycle.sh) and the
|
|
1055
|
+
// produceDrafts() helper are kept as the source those tools reuse; only the
|
|
1056
|
+
// draft_cycle tool registration was removed here.
|
|
1137
1057
|
// ---- post_drafts: post the user's chosen drafts from a batch ---------------
|
|
1138
1058
|
// Second half of the manual loop. The user reviewed the table from draft_cycle
|
|
1139
1059
|
// and said which numbers to post / edit; this posts exactly those. Editing a
|
|
1140
1060
|
// draft implies posting it. Indices are 1-based, matching the table.
|
|
1141
1061
|
tool("post_drafts", {
|
|
1142
1062
|
title: "Post chosen drafts",
|
|
1143
|
-
description: "Post the drafts the user approved from a
|
|
1144
|
-
"
|
|
1063
|
+
description: "Post the drafts the user approved from a submit_drafts batch. Pass the batch_id from " +
|
|
1064
|
+
"submit_drafts and the user's decision by NUMBER (1-based, matching the table): `post` is " +
|
|
1145
1065
|
"the list of draft numbers to post as drafted; `edits` rewrites a draft's text before " +
|
|
1146
1066
|
"posting it (editing implies posting); `post_all` posts every draft. Only the chosen " +
|
|
1147
1067
|
"drafts post; anything not listed is left unposted. Call this ONLY after the user has " +
|
|
1148
1068
|
"told you which drafts they want. After posting, call the `dashboard` tool so the user " +
|
|
1149
1069
|
"sees the updated state.",
|
|
1150
1070
|
inputSchema: {
|
|
1151
|
-
batch_id: z.string().describe("The batch_id returned by
|
|
1071
|
+
batch_id: z.string().describe("The batch_id returned by submit_drafts."),
|
|
1152
1072
|
post: z
|
|
1153
1073
|
.array(z.number().int().positive())
|
|
1154
1074
|
.optional()
|
|
@@ -1162,7 +1082,7 @@ tool("post_drafts", {
|
|
|
1162
1082
|
}, async ({ batch_id, post, edits, post_all }) => {
|
|
1163
1083
|
const plan = readPlan(batch_id);
|
|
1164
1084
|
if (!plan || !(plan.candidates && plan.candidates.length)) {
|
|
1165
|
-
return textContent(`No drafts found for batch ${batch_id}. Run
|
|
1085
|
+
return textContent(`No drafts found for batch ${batch_id}. Run scan_candidates then submit_drafts again to produce a fresh batch.`);
|
|
1166
1086
|
}
|
|
1167
1087
|
const candidates = plan.candidates;
|
|
1168
1088
|
const total = candidates.length;
|
|
@@ -1602,6 +1522,27 @@ function sapsStateDir() {
|
|
|
1602
1522
|
return (process.env.SAPS_STATE_DIR ||
|
|
1603
1523
|
path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp"));
|
|
1604
1524
|
}
|
|
1525
|
+
// activity.json: a tiny "what's running right now" signal the menu bar reads to
|
|
1526
|
+
// show a loading spinner + label (scanning / drafting / posting / …). Written by
|
|
1527
|
+
// long-running tools, cleared when they finish. Best-effort; absence == idle.
|
|
1528
|
+
function writeActivity(state, label) {
|
|
1529
|
+
try {
|
|
1530
|
+
const dir = sapsStateDir();
|
|
1531
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1532
|
+
fs.writeFileSync(path.join(dir, "activity.json"), JSON.stringify({ state, label, since: new Date().toISOString() }) + "\n", "utf-8");
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
/* best effort: a status write must never break the work it's narrating */
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
function clearActivity() {
|
|
1539
|
+
try {
|
|
1540
|
+
fs.rmSync(path.join(sapsStateDir(), "activity.json"), { force: true });
|
|
1541
|
+
}
|
|
1542
|
+
catch {
|
|
1543
|
+
/* best effort */
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1605
1546
|
// Signal the menu bar that a fresh draft batch is ready for pop-up review. The
|
|
1606
1547
|
// chat-table review path is unchanged and still works; this just ALSO lets the
|
|
1607
1548
|
// corner cards drive review (both surfaces de-dup via the plan's `posted` flag).
|
|
@@ -1636,13 +1577,189 @@ async function openInBrowser(url) {
|
|
|
1636
1577
|
console.error("[social-autoposter-mcp] openInBrowser failed:", e?.message || e);
|
|
1637
1578
|
}
|
|
1638
1579
|
}
|
|
1580
|
+
async function runScanCandidates(project, onProgress) {
|
|
1581
|
+
// SCAN_ONLY=1: run scan -> score -> top-N select, then STOP before drafting.
|
|
1582
|
+
// No DRAFT_ONLY, no `claude -p` drafting. TWITTER_PHASE1_LLM_DRAFT=0 forces the
|
|
1583
|
+
// deterministic query bank so the whole scan is claude-free (the agent does ALL
|
|
1584
|
+
// the AI). Off-screen by default (no BH_WINDOW_* / overlay): a scheduled run has
|
|
1585
|
+
// no human watching the Chrome.
|
|
1586
|
+
const env = {
|
|
1587
|
+
SCAN_ONLY: "1",
|
|
1588
|
+
TWITTER_PHASE1_LLM_DRAFT: "0",
|
|
1589
|
+
SAPS_REPO_DIR: repoDir(),
|
|
1590
|
+
PATH: pipelinePath(),
|
|
1591
|
+
};
|
|
1592
|
+
if (project)
|
|
1593
|
+
env.SAPS_FORCE_PROJECT = project;
|
|
1594
|
+
const chrome = resolveChrome();
|
|
1595
|
+
if (chrome)
|
|
1596
|
+
env.BH_CHROME_BIN = chrome;
|
|
1597
|
+
let step = 0;
|
|
1598
|
+
let lastMsg = "";
|
|
1599
|
+
const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
|
|
1600
|
+
env,
|
|
1601
|
+
timeoutMs: 900_000,
|
|
1602
|
+
onLine: (line) => {
|
|
1603
|
+
const t = line.replace(/\s+$/, "");
|
|
1604
|
+
if (t.trim())
|
|
1605
|
+
console.error(`[scan_candidates] ${t}`);
|
|
1606
|
+
if (!onProgress)
|
|
1607
|
+
return;
|
|
1608
|
+
const msg = cycleProgressMessage(t);
|
|
1609
|
+
if (msg && msg !== lastMsg) {
|
|
1610
|
+
lastMsg = msg;
|
|
1611
|
+
onProgress(msg, ++step);
|
|
1612
|
+
}
|
|
1613
|
+
},
|
|
1614
|
+
});
|
|
1615
|
+
const marker = /SCAN_ONLY_RESULT=(\/\S+\.json)/.exec(res.stdout + "\n" + res.stderr);
|
|
1616
|
+
if (marker && marker[1]) {
|
|
1617
|
+
try {
|
|
1618
|
+
const data = JSON.parse(fs.readFileSync(marker[1], "utf-8"));
|
|
1619
|
+
return {
|
|
1620
|
+
batchId: data.batch_id ?? null,
|
|
1621
|
+
candidates: (data.candidates ?? []),
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
catch (e) {
|
|
1625
|
+
return {
|
|
1626
|
+
batchId: null,
|
|
1627
|
+
candidates: [],
|
|
1628
|
+
blocked: `Scan finished but its result file was unreadable: ${e?.message || e}`,
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
// If query-gen (when enabled) hits a real failure, the cycle still emits
|
|
1633
|
+
// DRAFT_ONLY_BLOCKED=<reason>. Surface it rather than "no candidates".
|
|
1634
|
+
const blockedMarker = /DRAFT_ONLY_BLOCKED=([a-z0-9_]+)/.exec(res.stdout + "\n" + res.stderr);
|
|
1635
|
+
if (blockedMarker && blockedMarker[1]) {
|
|
1636
|
+
return { batchId: null, candidates: [], blocked: blockedReasonMessage(blockedMarker[1]) };
|
|
1637
|
+
}
|
|
1638
|
+
return {
|
|
1639
|
+
batchId: null,
|
|
1640
|
+
candidates: [],
|
|
1641
|
+
blocked: `This scan produced no candidates (exit ${res.code}). Usually a cold-start ` +
|
|
1642
|
+
`project with no seeded search topics, or nothing fresh on-theme right now. Tail:\n` +
|
|
1643
|
+
res.stderr.split("\n").slice(-12).join("\n"),
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
tool("scan_candidates", {
|
|
1647
|
+
title: "Scan X for reply candidates (no drafting, no posting)",
|
|
1648
|
+
description: "Step 1 of a hands-free / scheduled autopilot run. Runs the scan+score half of the pipeline " +
|
|
1649
|
+
"and returns the top scored X/Twitter threads worth replying to — WITHOUT drafting or posting, " +
|
|
1650
|
+
"and without spending any `claude -p` budget. You (this session) then draft an on-brand reply " +
|
|
1651
|
+
"for each good candidate YOURSELF and submit them with `submit_drafts`. Each candidate includes " +
|
|
1652
|
+
"its candidate_id (pass it back), the thread text/author, the matched project, and engagement " +
|
|
1653
|
+
"metrics. Optional `project` scopes the scan to one configured project.",
|
|
1654
|
+
inputSchema: { project: z.string().optional() },
|
|
1655
|
+
}, async (args) => {
|
|
1656
|
+
const scan = await runScanCandidates(args?.project);
|
|
1657
|
+
if (!scan.batchId) {
|
|
1658
|
+
return textContent(scan.blocked || "No candidates found.");
|
|
1659
|
+
}
|
|
1660
|
+
return jsonContent({
|
|
1661
|
+
batch_id: scan.batchId,
|
|
1662
|
+
count: scan.candidates.length,
|
|
1663
|
+
candidates: scan.candidates,
|
|
1664
|
+
next_step: `Draft an on-brand reply (<=250 chars, match the thread's language) for each candidate you ` +
|
|
1665
|
+
`judge genuinely worth engaging; skip the rest. Then call submit_drafts with batch_id ` +
|
|
1666
|
+
`"${scan.batchId}" and one entry per drafted reply ({candidate_id, reply_text}). Nothing posts ` +
|
|
1667
|
+
`until the user approves.`,
|
|
1668
|
+
});
|
|
1669
|
+
});
|
|
1670
|
+
tool("submit_drafts", {
|
|
1671
|
+
title: "Submit drafted replies for review",
|
|
1672
|
+
description: "Step 2 of a hands-free / scheduled autopilot run. Hand back the replies you drafted for the " +
|
|
1673
|
+
"candidates returned by scan_candidates. Writes them into the SAME review plan the menu-bar " +
|
|
1674
|
+
"approval UI and post_drafts already use — nothing is posted until the user approves. Provide " +
|
|
1675
|
+
"batch_id (from scan_candidates) and a `drafts` array; each entry needs candidate_id and " +
|
|
1676
|
+
"reply_text (engagement_style, language, link_keyword optional).",
|
|
1677
|
+
inputSchema: {
|
|
1678
|
+
batch_id: z.string(),
|
|
1679
|
+
drafts: z
|
|
1680
|
+
.array(z.object({
|
|
1681
|
+
candidate_id: z.union([z.string(), z.number()]),
|
|
1682
|
+
reply_text: z.string(),
|
|
1683
|
+
engagement_style: z.string().optional(),
|
|
1684
|
+
language: z.string().optional(),
|
|
1685
|
+
link_keyword: z.string().optional(),
|
|
1686
|
+
}))
|
|
1687
|
+
.min(1),
|
|
1688
|
+
},
|
|
1689
|
+
}, async (args) => {
|
|
1690
|
+
// Reload the scan candidates for thread metadata (url / author / text /
|
|
1691
|
+
// project / topic). If the scan file is gone (e.g. /tmp cleared), we still
|
|
1692
|
+
// build a plan from the drafts alone; the review cards just lack context.
|
|
1693
|
+
const scanById = new Map();
|
|
1694
|
+
try {
|
|
1695
|
+
const raw = JSON.parse(fs.readFileSync(scanResultPath(args.batch_id), "utf-8"));
|
|
1696
|
+
for (const c of (raw.candidates ?? [])) {
|
|
1697
|
+
scanById.set(String(c.id), c);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
catch {
|
|
1701
|
+
/* scan file missing — proceed with draft-only context */
|
|
1702
|
+
}
|
|
1703
|
+
const candidates = args.drafts.map((d) => {
|
|
1704
|
+
const sc = scanById.get(String(d.candidate_id));
|
|
1705
|
+
return {
|
|
1706
|
+
candidate_id: d.candidate_id,
|
|
1707
|
+
candidate_url: sc?.tweet_url,
|
|
1708
|
+
thread_author: sc?.author_handle,
|
|
1709
|
+
thread_text: sc?.tweet_text,
|
|
1710
|
+
reply_text: d.reply_text,
|
|
1711
|
+
engagement_style: d.engagement_style,
|
|
1712
|
+
language: d.language,
|
|
1713
|
+
link_keyword: d.link_keyword,
|
|
1714
|
+
search_topic: sc?.search_topic,
|
|
1715
|
+
matched_project: sc?.matched_project,
|
|
1716
|
+
};
|
|
1717
|
+
});
|
|
1718
|
+
const firstSc = scanById.get(String(args.drafts[0].candidate_id));
|
|
1719
|
+
const project = (candidates.map((c) => c.matched_project).find((p) => !!p) ||
|
|
1720
|
+
firstSc?.matched_project ||
|
|
1721
|
+
"default");
|
|
1722
|
+
const plan = { candidates };
|
|
1723
|
+
writePlan(args.batch_id, plan);
|
|
1724
|
+
// Bake link targets into the plan (sub-second at TWITTER_PAGE_GEN_RATE=0), the
|
|
1725
|
+
// same prep step DRAFT_ONLY does before handing off. Best-effort: posting
|
|
1726
|
+
// falls back to the plain project URL per-candidate if this is skipped.
|
|
1727
|
+
try {
|
|
1728
|
+
await runPython("scripts/twitter_gen_links.py", ["--plan", planPath(args.batch_id)], {
|
|
1729
|
+
timeoutMs: 120_000,
|
|
1730
|
+
env: { TWITTER_PAGE_GEN_RATE: "0", SAPS_REPO_DIR: repoDir(), PATH: pipelinePath() },
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
catch {
|
|
1734
|
+
/* best effort — plan still posts with a plain-URL fallback */
|
|
1735
|
+
}
|
|
1736
|
+
const finalPlan = readPlan(args.batch_id) ?? plan;
|
|
1737
|
+
const count = (finalPlan.candidates || []).length;
|
|
1738
|
+
// Drafts queued = the pipeline verified end-to-end without posting. This is the
|
|
1739
|
+
// onboarding "draft_verified" terminal goal (formerly completed by draft_cycle).
|
|
1740
|
+
if (count > 0)
|
|
1741
|
+
completeOnboardingMilestone("draft_verified", { outcome: "review_batch", draft_count: count });
|
|
1742
|
+
// Surface to the menu-bar review cards (same review plan draft_cycle used).
|
|
1743
|
+
writeReviewRequest({
|
|
1744
|
+
batch_id: args.batch_id,
|
|
1745
|
+
project,
|
|
1746
|
+
count,
|
|
1747
|
+
plan_path: planPath(args.batch_id),
|
|
1748
|
+
created_at: new Date().toISOString(),
|
|
1749
|
+
});
|
|
1750
|
+
return textContent(`${count} draft(s) queued for review (batch ${args.batch_id}). They're now in the menu-bar ` +
|
|
1751
|
+
`approval cards and the table below; nothing posts until approved.\n\n` +
|
|
1752
|
+
renderDraftsTable(finalPlan) +
|
|
1753
|
+
`\n\nTo post the approved ones: the user approves in the menu bar, or call post_drafts with the ` +
|
|
1754
|
+
`numbers to post.`);
|
|
1755
|
+
});
|
|
1639
1756
|
appTool("dashboard", {
|
|
1640
1757
|
title: "Social Autoposter dashboard",
|
|
1641
1758
|
description: "Render the Social Autoposter dashboard in chat: a visual surface showing project setup, X " +
|
|
1642
1759
|
"connection, autopilot state, and 7-day stats, with buttons to run a draft cycle, connect X, " +
|
|
1643
1760
|
"and refresh. Use when the user asks to see the dashboard, panel, " +
|
|
1644
1761
|
"status, or controls. ALSO call this at the end of any state-changing or results-producing " +
|
|
1645
|
-
"action (
|
|
1762
|
+
"action (scan_candidates, submit_drafts, post_drafts, get_stats) so the user sees the " +
|
|
1646
1763
|
"updated dashboard. Hosts without UI support get the same data as text.",
|
|
1647
1764
|
inputSchema: {},
|
|
1648
1765
|
// fallback_url is set only when the host can't render the ui:// resource and
|
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
|
+
}
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -13,7 +13,7 @@ import { repoDir, runPython } from "./repo.js";
|
|
|
13
13
|
import { VERSION } from "./version.js";
|
|
14
14
|
// Sentry DSN is a client-side identifier (safe to embed, same posture as Fazm's
|
|
15
15
|
// hardcoded Swift DSN). Overridable via env for dev. Empty -> Sentry disabled.
|
|
16
|
-
const EMBEDDED_DSN = "";
|
|
16
|
+
const EMBEDDED_DSN = "https://4d44ac907262c6545cf8681703528d04@o4507617161314304.ingest.us.sentry.io/4511598804336640";
|
|
17
17
|
const SENTRY_DSN = process.env.SAPS_SENTRY_DSN || EMBEDDED_DSN;
|
|
18
18
|
let sentryReady = false;
|
|
19
19
|
export function initSentry() {
|
package/mcp/dist/version.json
CHANGED
|
@@ -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
|
|
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
|
-
# ----
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
219
|
-
# review mid-
|
|
220
|
-
if self.
|
|
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
|
-
#
|
|
321
|
-
|
|
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
|
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.6.72",
|
|
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"
|
|
@@ -87,7 +87,6 @@
|
|
|
87
87
|
"!scripts/export_kent_handoff.py",
|
|
88
88
|
"!scripts/extract_user_messages_today.py",
|
|
89
89
|
"!scripts/fazm_seo_health.py",
|
|
90
|
-
"!scripts/fetch_twitter_t1.py",
|
|
91
90
|
"!scripts/fix_mdx_light_mode.py",
|
|
92
91
|
"!scripts/fix_style_lengths_20260601.py",
|
|
93
92
|
"!scripts/fix_svg_paragraph_wrap.py",
|
|
@@ -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
|
|
13
|
-
(keychain-independent)
|
|
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
|
|
36
|
-
#
|
|
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)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
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
|
|
171
|
-
"
|
|
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}...")
|
package/scripts/sentry_init.py
CHANGED
|
@@ -15,7 +15,7 @@ import os
|
|
|
15
15
|
|
|
16
16
|
# Client-side DSN: safe to embed, same posture as the Node telemetry and Fazm's
|
|
17
17
|
# hardcoded Swift DSN. Overridable via env for dev. Empty -> Sentry disabled.
|
|
18
|
-
_EMBEDDED_DSN = ""
|
|
18
|
+
_EMBEDDED_DSN = "https://4d44ac907262c6545cf8681703528d04@o4507617161314304.ingest.us.sentry.io/4511598804336640"
|
|
19
19
|
|
|
20
20
|
_initialized = False
|
|
21
21
|
|
|
@@ -60,15 +60,13 @@ except ImportError:
|
|
|
60
60
|
"websocket-client not installed (needed for CDP). pip install websocket-client"
|
|
61
61
|
)
|
|
62
62
|
|
|
63
|
-
#
|
|
64
|
-
# the
|
|
65
|
-
#
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
#
|
|
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
|
package/skill/lock.sh
CHANGED
|
@@ -617,10 +617,9 @@ defer_if_foreign_browser_mcp_active() {
|
|
|
617
617
|
|
|
618
618
|
# Explicit early release. Use this when a long-running script only needs the
|
|
619
619
|
# browser for part of its run (e.g. run-twitter-cycle.sh holds the lock for
|
|
620
|
-
# Phase 1 scrape, releases
|
|
621
|
-
#
|
|
622
|
-
#
|
|
623
|
-
# only sleeping.
|
|
620
|
+
# Phase 1 scrape, releases between Phase 1 and Phase 2b posting, then re-acquires
|
|
621
|
+
# before Phase 2b). Without this, sibling pipelines waiting on the same profile
|
|
622
|
+
# lock block for the full cycle even when the holder is not using the browser.
|
|
624
623
|
release_lock() {
|
|
625
624
|
local name="$1"
|
|
626
625
|
local lock_dir="/tmp/social-autoposter-${name}.lock"
|
|
@@ -12,18 +12,14 @@
|
|
|
12
12
|
# - scrape tweets via twitter-harness, enrich via fxtwitter -> T0 snapshot
|
|
13
13
|
# - store all candidates with batch_id and search_topic
|
|
14
14
|
#
|
|
15
|
-
#
|
|
15
|
+
# No ripen wait (variant D won the A/B/C/D test 2026-05-31): the cycle goes
|
|
16
|
+
# straight from discovery to drafting. There is NO 5-min sleep and NO fxtwitter
|
|
17
|
+
# T1 re-poll anywhere in the Twitter pipeline. delta_score stays at its T0
|
|
18
|
+
# value and is no longer a gate. Do not re-introduce a ripen/sleep step here.
|
|
16
19
|
#
|
|
17
|
-
# Phase 2 (
|
|
18
|
-
# -
|
|
19
|
-
#
|
|
20
|
-
# product-discussion tweets (asking for a tool, venting a pain point) can
|
|
21
|
-
# compete; ranking still favors growth via hybrid sort
|
|
22
|
-
# - Hybrid sort: ORDER BY (delta_score + product_intent_boost) where
|
|
23
|
-
# product_intent_boost is +5 when tweet text matches an intent-signal regex
|
|
24
|
-
# (wish/need/looking for/recommend/alternative/frustrated/etc); raw growth
|
|
25
|
-
# remains the dominant signal, but a slow-burn "anyone know a tool for..."
|
|
26
|
-
# tweet now ranks alongside fast-growing news/drama
|
|
20
|
+
# Phase 2 (immediately after Phase 1):
|
|
21
|
+
# - sort candidates by virality_score DESC (composite predictor stamped at
|
|
22
|
+
# discovery by score_twitter_candidates.py); no delta floor, no T1 re-poll
|
|
27
23
|
# - Claude reads top 25 (raised from 15 so the long tail reaches the model),
|
|
28
24
|
# drops unsuitable, posts every candidate it judges genuinely on-brand
|
|
29
25
|
# (no per-cycle post cap, no per-project quota)
|
|
@@ -203,9 +199,20 @@ log "Length-control experiment concluded 2026-06-04; winner=control; LENGTH_ARM
|
|
|
203
199
|
# spend the sysctl read for the next check.
|
|
204
200
|
source "$REPO_DIR/scripts/preflight.sh"
|
|
205
201
|
SA_PREFLIGHT_SCRIPT="run-twitter-cycle"
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
202
|
+
if [ "${SCAN_ONLY:-0}" = "1" ]; then
|
|
203
|
+
# SCAN_ONLY (the Desktop-session autopilot's scan step) gets its OWN slot pool
|
|
204
|
+
# so it is NOT blocked by a live launchd autopilot cycle; the two then serialize
|
|
205
|
+
# on the twitter-browser lock (acquired in Phase 1) instead of being mutually
|
|
206
|
+
# exclusive. It also SKIPS the claude-blocked gate: SCAN_ONLY drives no
|
|
207
|
+
# `claude -p`, so a prior claude cap must not suppress a pure scan.
|
|
208
|
+
SA_PREFLIGHT_SCRIPT="run-twitter-cycle-scan"
|
|
209
|
+
preflight_skip_if_jetsam_pressure
|
|
210
|
+
preflight_acquire_slot_or_skip "twitter-scan" 1
|
|
211
|
+
else
|
|
212
|
+
preflight_skip_if_claude_blocked
|
|
213
|
+
preflight_skip_if_jetsam_pressure
|
|
214
|
+
preflight_acquire_slot_or_skip "twitter-cycle" 1
|
|
215
|
+
fi
|
|
209
216
|
|
|
210
217
|
# Source lock helpers (functions only, no lock acquired here). Phase 0 + the
|
|
211
218
|
# project/queries setup below run lock-free against DB and config files;
|
|
@@ -401,7 +408,7 @@ python3 "$REPO_DIR/scripts/twitter_batch_phase.py" start "$BATCH_ID" --phase pha
|
|
|
401
408
|
# read from the owner's twitter_batches row:
|
|
402
409
|
# phase0 -> 5 min (just the salvage SQL)
|
|
403
410
|
# phase1 -> 20 min (Claude scan + scrape)
|
|
404
|
-
# phase2a -> 20 min (
|
|
411
|
+
# phase2a -> 20 min (browser-lock handoff window; no ripen wait since 2026-05-31)
|
|
405
412
|
# phase2b-prep -> 45 min (Claude reads threads + drafts; bumped 2026-05-15
|
|
406
413
|
# 15 -> 30 after 17:15 cycle was wrongly salvaged
|
|
407
414
|
# while queued behind 17:30's 42-min lock-hold;
|
|
@@ -1443,16 +1450,16 @@ if [ "$BATCH_COUNT" = "0" ]; then
|
|
|
1443
1450
|
exit 0
|
|
1444
1451
|
fi
|
|
1445
1452
|
|
|
1446
|
-
# Stamp phase2a before releasing the lock so the budget covers the
|
|
1447
|
-
#
|
|
1453
|
+
# Stamp phase2a before releasing the lock so the salvage budget covers the
|
|
1454
|
+
# browser-lock handoff window (phase2a budget = 20 min).
|
|
1448
1455
|
python3 "$REPO_DIR/scripts/twitter_batch_phase.py" advance "$BATCH_ID" --phase phase2a 2>&1 | tee -a "$LOG_FILE" || true
|
|
1449
1456
|
|
|
1450
|
-
# Release the twitter-browser lock
|
|
1457
|
+
# Release the twitter-browser lock between Phase 1 scrape and Phase 2b posting.
|
|
1451
1458
|
# Other pipelines (engage-twitter, dm-outreach-twitter, link-edit-twitter,
|
|
1452
1459
|
# stats.sh) can run their browser steps in this window instead of waiting for us
|
|
1453
1460
|
# to finish. We re-acquire just before Phase 2b posts, blocking up to the
|
|
1454
1461
|
# acquire_lock timeout if another pipeline is mid-run.
|
|
1455
|
-
log "Releasing twitter-browser lock
|
|
1462
|
+
log "Releasing twitter-browser lock between Phase 1 scrape and Phase 2b posting..."
|
|
1456
1463
|
release_lock "twitter-browser" 2>>"$LOG_FILE"
|
|
1457
1464
|
# (2026-06-16) NO `rm -f twitter-browser-lock.json` here. The blind rm was
|
|
1458
1465
|
# ownership-unaware and ran AFTER release_lock, so under a pipeline handoff it
|
|
@@ -1520,6 +1527,57 @@ if [ -z "$CANDIDATES" ]; then
|
|
|
1520
1527
|
exit 0
|
|
1521
1528
|
fi
|
|
1522
1529
|
|
|
1530
|
+
# --- SCAN_ONLY gate: stop after scoring, emit candidates, skip drafting -------
|
|
1531
|
+
# When SCAN_ONLY=1 the cycle runs scan -> score -> top-N select, writes the chosen
|
|
1532
|
+
# candidates as JSON, and STOPS before the claude drafting step. The MCP
|
|
1533
|
+
# scan_candidates tool reads this so a Claude Desktop scheduled-task session can do
|
|
1534
|
+
# the drafting ITSELF (on the user's plan, no `claude -p`) and hand the drafts back
|
|
1535
|
+
# via submit_drafts. Candidates stay 'pending' (drafted+posted via submit_drafts ->
|
|
1536
|
+
# post_drafts, or salvaged by a later cycle). The browser lock was already released
|
|
1537
|
+
# at the Phase 1 handoff, so this exits clean via the EXIT trap. NO current caller
|
|
1538
|
+
# sets SCAN_ONLY, so the autopilot/draft_cycle paths are byte-for-byte unchanged.
|
|
1539
|
+
if [ "${SCAN_ONLY:-0}" = "1" ]; then
|
|
1540
|
+
SCAN_FILE="/tmp/saps_scan_candidates_${BATCH_ID}.json"
|
|
1541
|
+
# $CANDIDATES is the same pipe-separated top-N the drafting step consumes (cols
|
|
1542
|
+
# documented in twitter_cycle_helper.py:cmd_candidates; tweet_text/draft fields
|
|
1543
|
+
# are pipe+newline sanitized there, so a field split is safe). Batch id + out
|
|
1544
|
+
# path travel via env so the single-quoted python needs no shell interpolation.
|
|
1545
|
+
printf '%s\n' "$CANDIDATES" | SAPS_SCAN_FILE="$SCAN_FILE" SAPS_SCAN_BATCH="$BATCH_ID" python3 -c '
|
|
1546
|
+
import json, os, sys
|
|
1547
|
+
def _i(x):
|
|
1548
|
+
try:
|
|
1549
|
+
return int(float(x or 0))
|
|
1550
|
+
except Exception:
|
|
1551
|
+
return 0
|
|
1552
|
+
def _f(x):
|
|
1553
|
+
try:
|
|
1554
|
+
return float(x or 0)
|
|
1555
|
+
except Exception:
|
|
1556
|
+
return 0.0
|
|
1557
|
+
out = []
|
|
1558
|
+
for line in sys.stdin:
|
|
1559
|
+
line = line.rstrip("\n")
|
|
1560
|
+
if not line.strip():
|
|
1561
|
+
continue
|
|
1562
|
+
p = line.split("|")
|
|
1563
|
+
if len(p) < 14 or not p[0].isdigit():
|
|
1564
|
+
continue
|
|
1565
|
+
out.append({
|
|
1566
|
+
"id": int(p[0]), "tweet_url": p[1], "author_handle": p[2], "tweet_text": p[3],
|
|
1567
|
+
"virality_score": _f(p[4]), "delta_score": _f(p[5]), "matched_project": p[6],
|
|
1568
|
+
"search_topic": p[7], "likes": _i(p[8]), "retweets": _i(p[9]), "replies": _i(p[10]),
|
|
1569
|
+
"views": _i(p[11]), "author_followers": _i(p[12]), "age_hours": _f(p[13]),
|
|
1570
|
+
"existing_draft": p[14] if len(p) > 14 else "", "existing_draft_style": p[15] if len(p) > 15 else "",
|
|
1571
|
+
})
|
|
1572
|
+
json.dump({"batch_id": os.environ["SAPS_SCAN_BATCH"], "candidates": out}, open(os.environ["SAPS_SCAN_FILE"], "w"))
|
|
1573
|
+
' 2>/dev/null || printf '{"batch_id": "%s", "candidates": []}' "$BATCH_ID" > "$SCAN_FILE"
|
|
1574
|
+
SCAN_N=$(python3 -c "import json; print(len(json.load(open('$SCAN_FILE')).get('candidates') or []))" 2>/dev/null || echo 0)
|
|
1575
|
+
log "SCAN_ONLY=1: $SCAN_N candidate(s) scored and written to $SCAN_FILE. Stopping before drafting (agent drafts next)."
|
|
1576
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
1577
|
+
echo "SCAN_ONLY_RESULT=$SCAN_FILE"
|
|
1578
|
+
exit 0
|
|
1579
|
+
fi
|
|
1580
|
+
|
|
1523
1581
|
CANDIDATE_COUNT=$(printf '%s\n' "$CANDIDATES" | grep -c '^[0-9]')
|
|
1524
1582
|
log "Top $CANDIDATE_COUNT candidates by virality_score selected for post review."
|
|
1525
1583
|
|