@m13v/s4l 1.7.12-rc.3 → 1.7.12-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/dist/index.js +114 -5
- package/mcp/dist/repo.js +65 -5
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/cdp_drive.py +102 -0
- package/scripts/cdp_ready_check.py +56 -9
- package/scripts/draft_prompt_core.py +15 -2
- package/scripts/dump_visitor_history.py +72 -0
- package/scripts/engage_github.py +62 -3
- package/scripts/ingest_human_github_replies.py +205 -0
- package/scripts/invent_topics.py +10 -3
- package/scripts/pick_thread_target.py +4 -0
- package/scripts/pick_twitter_thread_target.py +4 -0
- package/scripts/project_topics.py +59 -1
- package/scripts/qualified_query_bank.py +53 -2
- package/scripts/reap_stale_claude_sessions.py +155 -4
- package/scripts/reddit_query_bank.py +42 -2
- package/scripts/reply_db.py +19 -0
- package/scripts/scan_github_replies.py +34 -8
- package/scripts/scrape_linkedin_comment_stats.py +25 -7
- package/scripts/tlh_pick_504.py +115 -0
- package/scripts/tlh_pick_505.py +61 -0
- package/scripts/update_linkedin_stats_from_feed.py +20 -1
- package/skill/check-web-chats.sh +45 -0
- package/skill/run-twitter-threads.sh +51 -0
package/mcp/dist/index.js
CHANGED
|
@@ -5271,6 +5271,43 @@ function readPanelEndpoint() {
|
|
|
5271
5271
|
return null;
|
|
5272
5272
|
}
|
|
5273
5273
|
}
|
|
5274
|
+
// A live pid is not enough to trust panel-endpoint.json: the exit-hook unlink
|
|
5275
|
+
// never runs on SIGKILL, so the file can point at a dead pid that the OS later
|
|
5276
|
+
// RECYCLES for an unrelated process — kill(pid, 0) then reports "alive" and
|
|
5277
|
+
// every instance would cede forever (no panel owner, no heartbeat/relay owner)
|
|
5278
|
+
// until the squatter happens to exit. Accept the registration only when the
|
|
5279
|
+
// pid's command line actually looks like an S4L MCP server; on ps failure fall
|
|
5280
|
+
// back to liveness alone rather than stealing a possibly-valid slot.
|
|
5281
|
+
function isS4lServerPid(pid) {
|
|
5282
|
+
if (!isPidAlive(pid))
|
|
5283
|
+
return false;
|
|
5284
|
+
try {
|
|
5285
|
+
const cmd = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
|
|
5286
|
+
encoding: "utf-8",
|
|
5287
|
+
timeout: 4000,
|
|
5288
|
+
});
|
|
5289
|
+
return cmd.includes("dist/index.js") || cmd.includes("@m13v/s4l-mcp");
|
|
5290
|
+
}
|
|
5291
|
+
catch {
|
|
5292
|
+
return true;
|
|
5293
|
+
}
|
|
5294
|
+
}
|
|
5295
|
+
// True when this process should run per-INSTALL periodic work (heartbeat,
|
|
5296
|
+
// transcript/provider relays, state snapshots). Several server instances are
|
|
5297
|
+
// routinely alive at once (Desktop, Code side-panel, per-minute queue workers —
|
|
5298
|
+
// each boots its own copy via the ~/.claude.json registration), and before this
|
|
5299
|
+
// gate every one of them ran every timer: 16 concurrent instances meant 48
|
|
5300
|
+
// heartbeat python spawns per 15 min and a measured ~11k fs-events/sec from
|
|
5301
|
+
// duplicate relay scans. Reuse the panel-endpoint election writePanelUrl already
|
|
5302
|
+
// maintains: the registered live S4L-server pid is the designated worker; when
|
|
5303
|
+
// the file is absent, stale, or squatted, anyone may run the work (the 120s
|
|
5304
|
+
// reclaim tick will elect a new owner shortly).
|
|
5305
|
+
function isInstallTickOwner() {
|
|
5306
|
+
const existing = readPanelEndpoint();
|
|
5307
|
+
if (!existing?.pid || existing.pid === process.pid)
|
|
5308
|
+
return true;
|
|
5309
|
+
return !isS4lServerPid(existing.pid);
|
|
5310
|
+
}
|
|
5274
5311
|
// Publish the loopback URL to stable files so out-of-process readers can find
|
|
5275
5312
|
// the ephemeral port without scraping `lsof`:
|
|
5276
5313
|
// - panel-url plain text, for the Claude Code side-panel reverse proxy.
|
|
@@ -5295,7 +5332,7 @@ function writePanelUrl(url) {
|
|
|
5295
5332
|
fs.mkdirSync(dir, { recursive: true });
|
|
5296
5333
|
fs.writeFileSync(path.join(dir, "panel-url"), url, "utf-8");
|
|
5297
5334
|
const existing = readPanelEndpoint();
|
|
5298
|
-
if (existing?.pid && existing.pid !== process.pid &&
|
|
5335
|
+
if (existing?.pid && existing.pid !== process.pid && isS4lServerPid(existing.pid)) {
|
|
5299
5336
|
// Someone else already holds a live registration (most likely a longer-
|
|
5300
5337
|
// lived session than us) — don't clobber it. Our own panel is still up
|
|
5301
5338
|
// and fully usable via `url` for anything that already has it (e.g. this
|
|
@@ -6212,6 +6249,67 @@ async function main() {
|
|
|
6212
6249
|
const transport = new StdioServerTransport();
|
|
6213
6250
|
await server.connect(transport);
|
|
6214
6251
|
console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
|
|
6252
|
+
// Exit when the client goes away. The SDK's StdioServerTransport listens only
|
|
6253
|
+
// for stdin 'data'/'error' — never 'end'/'close' — so client death is invisible
|
|
6254
|
+
// to it, and the eagerly-started panel HTTP listener below holds the event loop
|
|
6255
|
+
// open forever. That combination orphaned 16 servers (33 GB RSS, duplicate
|
|
6256
|
+
// autopilot timers) on 2026-09-18: every client exit (clean close OR SIGKILL,
|
|
6257
|
+
// which just closes our stdin pipe) left a full server behind, reparented to
|
|
6258
|
+
// launchd. Handle EOF ourselves, plus signals, plus a ppid backstop for the
|
|
6259
|
+
// rare case where an inherited fd keeps the stdin pipe open past client death.
|
|
6260
|
+
let shutdownStarted = false;
|
|
6261
|
+
const exitNow = (reason) => {
|
|
6262
|
+
console.error(`[social-autoposter-mcp] client gone (${reason}); exiting`);
|
|
6263
|
+
try {
|
|
6264
|
+
localPanel?.server.close();
|
|
6265
|
+
}
|
|
6266
|
+
catch {
|
|
6267
|
+
/* best-effort */
|
|
6268
|
+
}
|
|
6269
|
+
process.exit(0);
|
|
6270
|
+
};
|
|
6271
|
+
const shutdown = (reason, force = false) => {
|
|
6272
|
+
if (shutdownStarted) {
|
|
6273
|
+
// A second signal while we wait out a drain means "stop now".
|
|
6274
|
+
if (force)
|
|
6275
|
+
exitNow(`${reason}, forced`);
|
|
6276
|
+
return;
|
|
6277
|
+
}
|
|
6278
|
+
shutdownStarted = true;
|
|
6279
|
+
if (!postingActive) {
|
|
6280
|
+
exitNow(reason);
|
|
6281
|
+
return;
|
|
6282
|
+
}
|
|
6283
|
+
// A posting drain is in flight: this process holds the shared
|
|
6284
|
+
// twitter-browser shell lock under its OWN pid while a python poster child
|
|
6285
|
+
// drives Chrome. Exiting now (a) lets the next per-minute cycle
|
|
6286
|
+
// stale-reclaim the lock mid-post and (b) crashes the poster on EPIPE
|
|
6287
|
+
// between a landed reply and its bookkeeping — the idempotent redrain then
|
|
6288
|
+
// double-posts. Wait for the drain to finish (the interval is deliberately
|
|
6289
|
+
// ref'd so it keeps the process alive), capped at the approve_drafts
|
|
6290
|
+
// worst-case timeout.
|
|
6291
|
+
console.error(`[social-autoposter-mcp] client gone (${reason}); waiting for in-flight posting drain before exit`);
|
|
6292
|
+
const started = Date.now();
|
|
6293
|
+
const wait = setInterval(() => {
|
|
6294
|
+
if (!postingActive || Date.now() - started > 2 * 3600_000) {
|
|
6295
|
+
clearInterval(wait);
|
|
6296
|
+
exitNow(`${reason}, drain done`);
|
|
6297
|
+
}
|
|
6298
|
+
}, 5_000);
|
|
6299
|
+
};
|
|
6300
|
+
process.stdin.on("end", () => shutdown("stdin end"));
|
|
6301
|
+
process.stdin.on("close", () => shutdown("stdin close"));
|
|
6302
|
+
process.on("SIGTERM", () => shutdown("SIGTERM", true));
|
|
6303
|
+
process.on("SIGINT", () => shutdown("SIGINT", true));
|
|
6304
|
+
// Skip the backstop when we were BORN with ppid 1 (a launchd-managed server is
|
|
6305
|
+
// legitimately parented to launchd; only a reparent-after-birth means orphaned).
|
|
6306
|
+
if (process.ppid !== 1) {
|
|
6307
|
+
const ppidWatch = setInterval(() => {
|
|
6308
|
+
if (process.ppid === 1)
|
|
6309
|
+
shutdown("orphaned (ppid=1)");
|
|
6310
|
+
}, 30_000);
|
|
6311
|
+
ppidWatch.unref();
|
|
6312
|
+
}
|
|
6215
6313
|
// Eagerly start the loopback panel server so the Claude Code side panel (and any
|
|
6216
6314
|
// reverse proxy in front of it) always has a backend to hit, without waiting for
|
|
6217
6315
|
// a first `dashboard` call. Best-effort: a bind failure must never block boot.
|
|
@@ -6245,8 +6343,16 @@ async function main() {
|
|
|
6245
6343
|
// (parity with the npx launchd heartbeat). Once on startup, then every 15m
|
|
6246
6344
|
// while the desktop app keeps the server alive. unref() so it never holds the
|
|
6247
6345
|
// process open past a normal exit.
|
|
6346
|
+
// Heartbeat + the relays + snapshots below are per-INSTALL work: gate every
|
|
6347
|
+
// tick on the panel-endpoint election (isInstallTickOwner) so N concurrent
|
|
6348
|
+
// instances produce 1x the spawns, not Nx. The startup heartbeat stays
|
|
6349
|
+
// unconditional — a fresh install must phone home before it ever wins an
|
|
6350
|
+
// election.
|
|
6248
6351
|
void sendHeartbeat("startup");
|
|
6249
|
-
const hb = setInterval(() =>
|
|
6352
|
+
const hb = setInterval(() => {
|
|
6353
|
+
if (isInstallTickOwner())
|
|
6354
|
+
void sendHeartbeat("interval");
|
|
6355
|
+
}, 15 * 60_000);
|
|
6250
6356
|
hb.unref();
|
|
6251
6357
|
// Ship Claude session transcripts (scheduled queue-worker runs + s4l repo
|
|
6252
6358
|
// sessions) to the Cloud Logging relay so a user's session can be
|
|
@@ -6257,7 +6363,7 @@ async function main() {
|
|
|
6257
6363
|
if ((process.env.S4L_TRANSCRIPT_RELAY ?? "1") !== "0") {
|
|
6258
6364
|
let transcriptRelayRunning = false;
|
|
6259
6365
|
const relayTranscripts = () => {
|
|
6260
|
-
if (transcriptRelayRunning)
|
|
6366
|
+
if (transcriptRelayRunning || !isInstallTickOwner())
|
|
6261
6367
|
return;
|
|
6262
6368
|
transcriptRelayRunning = true;
|
|
6263
6369
|
runPython("scripts/relay_session_transcripts.py", ["--max-lines", "600"], {
|
|
@@ -6283,7 +6389,7 @@ async function main() {
|
|
|
6283
6389
|
if ((process.env.S4L_PROVIDER_LOG_RELAY ?? "1") !== "0") {
|
|
6284
6390
|
let providerRelayRunning = false;
|
|
6285
6391
|
const relayProviderLog = () => {
|
|
6286
|
-
if (providerRelayRunning)
|
|
6392
|
+
if (providerRelayRunning || !isInstallTickOwner())
|
|
6287
6393
|
return;
|
|
6288
6394
|
providerRelayRunning = true;
|
|
6289
6395
|
runPython("scripts/relay_provider_log.py", ["--max-lines", "500"], {
|
|
@@ -6306,7 +6412,10 @@ async function main() {
|
|
|
6306
6412
|
// the recurring tick only POSTs when something actually changed; setup.ts
|
|
6307
6413
|
// additionally fires it right after every config write.
|
|
6308
6414
|
void sendStateSnapshot("startup");
|
|
6309
|
-
const ss = setInterval(() =>
|
|
6415
|
+
const ss = setInterval(() => {
|
|
6416
|
+
if (isInstallTickOwner())
|
|
6417
|
+
void sendStateSnapshot("interval");
|
|
6418
|
+
}, 15 * 60_000);
|
|
6310
6419
|
ss.unref();
|
|
6311
6420
|
// Voice-exemplar catch-up + periodic refresh, checked on every boot
|
|
6312
6421
|
// (2026-07-16, extended from the original one-shot-for-legacy-installs
|
package/mcp/dist/repo.js
CHANGED
|
@@ -85,6 +85,21 @@ export function run(cmd, args, opts = {}) {
|
|
|
85
85
|
}
|
|
86
86
|
let stdout = "";
|
|
87
87
|
let stderr = "";
|
|
88
|
+
// Cap captured output to a tail. run() used to accumulate a child's full
|
|
89
|
+
// stdout+stderr unbounded for the child's lifetime; long-lived spawns (the
|
|
90
|
+
// 13h voice backfill) and stuck children pinned those strings and were the
|
|
91
|
+
// main driver of multi-GB server heaps. Callers that parse output read
|
|
92
|
+
// either whole small payloads or the LAST line, so keeping the tail
|
|
93
|
+
// preserves semantics. 8 MB per stream is far above any legitimate payload.
|
|
94
|
+
const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
|
|
95
|
+
const capTail = (s) => s.length > MAX_CAPTURE_BYTES ? s.slice(s.length - (MAX_CAPTURE_BYTES >> 1)) : s;
|
|
96
|
+
// The line-splitter buffers below only shed at '\n', so a child emitting
|
|
97
|
+
// long newline-free output (carriage-return progress bars, one huge line)
|
|
98
|
+
// would grow them unbounded for its lifetime — the same heap pathology the
|
|
99
|
+
// capture cap exists for. Keep only a tail of an oversized partial line
|
|
100
|
+
// (telemetry truncates lines to 8KB anyway).
|
|
101
|
+
const MAX_LINEBUF_BYTES = 1024 * 1024;
|
|
102
|
+
const capLineBuf = (buf) => buf.length > MAX_LINEBUF_BYTES ? buf.slice(-8192) : buf;
|
|
88
103
|
// Per-stream partial-line buffers so onLine fires on whole lines only,
|
|
89
104
|
// regardless of how the OS chunks the pipe reads.
|
|
90
105
|
let outBuf = "";
|
|
@@ -104,7 +119,7 @@ export function run(cmd, args, opts = {}) {
|
|
|
104
119
|
/* a progress sink must never break the wrapped command */
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
|
-
return buf;
|
|
122
|
+
return capLineBuf(buf);
|
|
108
123
|
};
|
|
109
124
|
// Parallel whole-line splitter that tees to the telemetry sink (if any),
|
|
110
125
|
// kept separate from the onLine pump so neither path can affect the other.
|
|
@@ -127,29 +142,56 @@ export function run(cmd, args, opts = {}) {
|
|
|
127
142
|
/* the telemetry sink must never break the wrapped command */
|
|
128
143
|
}
|
|
129
144
|
}
|
|
130
|
-
return buf;
|
|
145
|
+
return capLineBuf(buf);
|
|
131
146
|
};
|
|
147
|
+
let settled = false;
|
|
132
148
|
let timer;
|
|
149
|
+
let killTimer;
|
|
133
150
|
if (opts.timeoutMs) {
|
|
134
151
|
timer = setTimeout(() => {
|
|
135
152
|
child.kill("SIGTERM");
|
|
153
|
+
// A child that ignores SIGTERM used to live (and hold its pipes and our
|
|
154
|
+
// buffers) forever. Escalate — but GENEROUSLY: children trap SIGTERM on
|
|
155
|
+
// purpose to finish critical bookkeeping (twitter_post_plan.py finishes
|
|
156
|
+
// the current candidate's post + recording, which can take minutes, so a
|
|
157
|
+
// fast SIGKILL would reopen the posted-but-unrecorded double-post
|
|
158
|
+
// window). 15 min covers every graceful path; only a truly wedged child
|
|
159
|
+
// gets hard-killed.
|
|
160
|
+
killTimer = setTimeout(() => {
|
|
161
|
+
try {
|
|
162
|
+
child.kill("SIGKILL");
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* already gone */
|
|
166
|
+
}
|
|
167
|
+
}, 15 * 60_000);
|
|
168
|
+
killTimer.unref();
|
|
136
169
|
}, opts.timeoutMs);
|
|
137
170
|
}
|
|
138
171
|
child.stdout.on("data", (d) => {
|
|
172
|
+
if (settled)
|
|
173
|
+
return;
|
|
139
174
|
const s = d.toString();
|
|
140
|
-
stdout
|
|
175
|
+
stdout = capTail(stdout + s);
|
|
141
176
|
outBuf = pump(s, "stdout", outBuf);
|
|
142
177
|
sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
|
|
143
178
|
});
|
|
144
179
|
child.stderr.on("data", (d) => {
|
|
180
|
+
if (settled)
|
|
181
|
+
return;
|
|
145
182
|
const s = d.toString();
|
|
146
|
-
stderr
|
|
183
|
+
stderr = capTail(stderr + s);
|
|
147
184
|
errBuf = pump(s, "stderr", errBuf);
|
|
148
185
|
sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
|
|
149
186
|
});
|
|
150
|
-
|
|
187
|
+
const finish = (code) => {
|
|
188
|
+
if (settled)
|
|
189
|
+
return;
|
|
190
|
+
settled = true;
|
|
151
191
|
if (timer)
|
|
152
192
|
clearTimeout(timer);
|
|
193
|
+
if (killTimer)
|
|
194
|
+
clearTimeout(killTimer);
|
|
153
195
|
// Flush any trailing partial line (output with no terminating newline).
|
|
154
196
|
if (opts.onLine) {
|
|
155
197
|
if (outBuf)
|
|
@@ -185,10 +227,28 @@ export function run(cmd, args, opts = {}) {
|
|
|
185
227
|
}
|
|
186
228
|
}
|
|
187
229
|
resolve({ code: code ?? -1, stdout, stderr });
|
|
230
|
+
};
|
|
231
|
+
child.on("close", (code) => finish(code));
|
|
232
|
+
child.on("exit", (code) => {
|
|
233
|
+
// 'close' waits for the stdio pipes to drain, which never happens when a
|
|
234
|
+
// grandchild inherited them and outlives the child — the promise (and the
|
|
235
|
+
// captured output) then leaked for the grandchild's lifetime. After the
|
|
236
|
+
// child itself exits, give the pipes a grace to flush, then settle. The
|
|
237
|
+
// setImmediate hop matters: if the event loop was stalled past the grace
|
|
238
|
+
// by sync work, timers fire BEFORE the poll phase reads the data already
|
|
239
|
+
// sitting in the kernel buffer — hopping to the check phase lets those
|
|
240
|
+
// reads land first instead of truncating a successful child's output.
|
|
241
|
+
const drain = setTimeout(() => setImmediate(() => finish(code)), 10_000);
|
|
242
|
+
drain.unref();
|
|
188
243
|
});
|
|
189
244
|
child.on("error", (err) => {
|
|
245
|
+
if (settled)
|
|
246
|
+
return;
|
|
247
|
+
settled = true;
|
|
190
248
|
if (timer)
|
|
191
249
|
clearTimeout(timer);
|
|
250
|
+
if (killTimer)
|
|
251
|
+
clearTimeout(killTimer);
|
|
192
252
|
resolve({ code: -1, stdout, stderr: stderr + String(err) });
|
|
193
253
|
});
|
|
194
254
|
});
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.12-rc.
|
|
5
|
+
"version": "1.7.12-rc.6",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.12-rc.
|
|
3
|
+
"version": "1.7.12-rc.6",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Minimal CDP driver for the harness Chrome on port 9556.
|
|
3
|
+
Usage:
|
|
4
|
+
cdp_drive.py nav <url> -> navigate active page tab, print final href
|
|
5
|
+
cdp_drive.py href -> print current href of active page tab
|
|
6
|
+
cdp_drive.py shot <path> -> screenshot active page tab to path
|
|
7
|
+
cdp_drive.py click <x> <y> -> dispatch mouse click at viewport coords
|
|
8
|
+
cdp_drive.py eval <expr> -> evaluate JS expression, print JSON result
|
|
9
|
+
"""
|
|
10
|
+
import sys, json, time, base64
|
|
11
|
+
import urllib.request
|
|
12
|
+
import websocket # websocket-client
|
|
13
|
+
|
|
14
|
+
PORT = 9556
|
|
15
|
+
BASE = f"http://localhost:{PORT}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def list_tabs():
|
|
19
|
+
with urllib.request.urlopen(f"{BASE}/json/list", timeout=10) as r:
|
|
20
|
+
return json.load(r)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def active_page():
|
|
24
|
+
tabs = list_tabs()
|
|
25
|
+
pages = [t for t in tabs if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
|
|
26
|
+
# prefer a linkedin tab, else first page
|
|
27
|
+
for t in pages:
|
|
28
|
+
if "linkedin.com" in (t.get("url") or ""):
|
|
29
|
+
return t
|
|
30
|
+
if pages:
|
|
31
|
+
return pages[0]
|
|
32
|
+
raise RuntimeError("no page tab")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CDP:
|
|
36
|
+
def __init__(self, ws_url):
|
|
37
|
+
self.ws = websocket.create_connection(
|
|
38
|
+
ws_url, max_size=None, timeout=60, suppress_origin=True
|
|
39
|
+
)
|
|
40
|
+
self._id = 0
|
|
41
|
+
|
|
42
|
+
def cmd(self, method, **params):
|
|
43
|
+
self._id += 1
|
|
44
|
+
mid = self._id
|
|
45
|
+
self.ws.send(json.dumps({"id": mid, "method": method, "params": params}))
|
|
46
|
+
while True:
|
|
47
|
+
msg = json.loads(self.ws.recv())
|
|
48
|
+
if msg.get("id") == mid:
|
|
49
|
+
if "error" in msg:
|
|
50
|
+
raise RuntimeError(msg["error"])
|
|
51
|
+
return msg.get("result", {})
|
|
52
|
+
|
|
53
|
+
def close(self):
|
|
54
|
+
try:
|
|
55
|
+
self.ws.close()
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_href(c):
|
|
61
|
+
r = c.cmd("Runtime.evaluate", expression="location.href", returnByValue=True)
|
|
62
|
+
return r.get("result", {}).get("value")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
action = sys.argv[1]
|
|
67
|
+
tab = active_page()
|
|
68
|
+
c = CDP(tab["webSocketDebuggerUrl"])
|
|
69
|
+
try:
|
|
70
|
+
if action in ("nav", "click"):
|
|
71
|
+
c.cmd("Page.enable")
|
|
72
|
+
if action == "nav":
|
|
73
|
+
url = sys.argv[2]
|
|
74
|
+
c.cmd("Page.navigate", url=url)
|
|
75
|
+
time.sleep(6)
|
|
76
|
+
print(get_href(c))
|
|
77
|
+
elif action == "href":
|
|
78
|
+
print(get_href(c))
|
|
79
|
+
elif action == "shot":
|
|
80
|
+
path = sys.argv[2]
|
|
81
|
+
r = c.cmd("Page.captureScreenshot", format="png")
|
|
82
|
+
with open(path, "wb") as f:
|
|
83
|
+
f.write(base64.b64decode(r["data"]))
|
|
84
|
+
print(path)
|
|
85
|
+
elif action == "click":
|
|
86
|
+
x = float(sys.argv[2]); y = float(sys.argv[3])
|
|
87
|
+
c.cmd("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button="left", clickCount=1)
|
|
88
|
+
c.cmd("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button="left", clickCount=1)
|
|
89
|
+
time.sleep(4)
|
|
90
|
+
print(get_href(c))
|
|
91
|
+
elif action == "eval":
|
|
92
|
+
expr = sys.argv[2]
|
|
93
|
+
r = c.cmd("Runtime.evaluate", expression=expr, returnByValue=True, awaitPromise=True)
|
|
94
|
+
print(json.dumps(r.get("result", {}).get("value")))
|
|
95
|
+
else:
|
|
96
|
+
print("unknown action", file=sys.stderr); sys.exit(2)
|
|
97
|
+
finally:
|
|
98
|
+
c.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
|
@@ -24,6 +24,18 @@ crashed ("Aw, Snap") keeps its title/url in every listing while sitting dead;
|
|
|
24
24
|
probe each page with a trivial Runtime.evaluate and reload it IN PLACE on
|
|
25
25
|
failure — fresh renderer, no kill, no new window, no focus change.
|
|
26
26
|
|
|
27
|
+
DEAD SESSION DISPATCH (2026-08-20, recurred 2026-09-01): a parked tab whose
|
|
28
|
+
DevTools SESSION dispatch is dead never replies to any per-tab command, so the
|
|
29
|
+
in-place Page.reload hangs too and cannot revive it — while the browser-level
|
|
30
|
+
ws stays healthy and this probe used to report ready anyway (Playwright then
|
|
31
|
+
hung at connect for every posting attempt; on 09-01 that burned all 5 drain
|
|
32
|
+
retries on two human-approved drafts). The reload is therefore RE-VERIFIED
|
|
33
|
+
with a second Runtime.evaluate; a tab that still doesn't answer gets REPLACED
|
|
34
|
+
via browser-level Target.createTarget(background=True) + Target.closeTarget
|
|
35
|
+
(the proven fix both incidents — no Chrome restart, no focus steal, session
|
|
36
|
+
cookies intact). Only if replacement itself fails does the verdict flip to
|
|
37
|
+
ready=false, so hc_ensure_browser's two-strike reap finally has a real signal.
|
|
38
|
+
|
|
27
39
|
Usage: cdp_ready_check.py [CDP_URL] [TIMEOUT_MS]
|
|
28
40
|
|
|
29
41
|
Prints a one-line JSON verdict to stdout (same shape/keys as before; mode is
|
|
@@ -84,9 +96,13 @@ def main() -> int:
|
|
|
84
96
|
info = _get_json(f"{url}/json/version", timeout=3)
|
|
85
97
|
_ws_call(info["webSocketDebuggerUrl"], "Browser.getVersion", timeout_s)
|
|
86
98
|
|
|
87
|
-
# Renderer-liveness sweep
|
|
88
|
-
#
|
|
99
|
+
# Renderer-liveness sweep. A crashed renderer revives with an in-place
|
|
100
|
+
# reload; a tab with dead session dispatch (reload hangs too) gets
|
|
101
|
+
# REPLACED via browser-level commands. Only an unfixable page tab
|
|
102
|
+
# fails the verdict — see DEAD SESSION DISPATCH in the docstring.
|
|
89
103
|
revived = 0
|
|
104
|
+
replaced = 0
|
|
105
|
+
dead = 0
|
|
90
106
|
pages = []
|
|
91
107
|
try:
|
|
92
108
|
pages = [t for t in _get_json(f"{url}/json/list", timeout=3)
|
|
@@ -95,23 +111,54 @@ def main() -> int:
|
|
|
95
111
|
try:
|
|
96
112
|
_ws_call(t["webSocketDebuggerUrl"], "Runtime.evaluate",
|
|
97
113
|
4.0, expression="1")
|
|
114
|
+
continue
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
try:
|
|
118
|
+
_ws_call(t["webSocketDebuggerUrl"], "Page.reload", 8.0)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
# Re-verify: a reload that went through proves dispatch is
|
|
122
|
+
# alive again; one that hung proves it never will be.
|
|
123
|
+
try:
|
|
124
|
+
_ws_call(t["webSocketDebuggerUrl"], "Runtime.evaluate",
|
|
125
|
+
4.0, expression="1")
|
|
126
|
+
revived += 1
|
|
127
|
+
continue
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
# Dead session dispatch: replace the TAB, keep Chrome. Only
|
|
131
|
+
# http(s) tabs are recreated (the harness daemon's
|
|
132
|
+
# is_real_page refuses about:/chrome: tabs); the dead one is
|
|
133
|
+
# closed only after its replacement exists so the browser is
|
|
134
|
+
# never left tabless.
|
|
135
|
+
page_url = t.get("url") or ""
|
|
136
|
+
try:
|
|
137
|
+
if not page_url.startswith("http"):
|
|
138
|
+
raise ValueError(f"unreplaceable url {page_url[:40]!r}")
|
|
139
|
+
_ws_call(info["webSocketDebuggerUrl"], "Target.createTarget",
|
|
140
|
+
8.0, url=page_url, background=True)
|
|
141
|
+
_ws_call(info["webSocketDebuggerUrl"], "Target.closeTarget",
|
|
142
|
+
8.0, targetId=t["id"])
|
|
143
|
+
replaced += 1
|
|
98
144
|
except Exception:
|
|
99
|
-
|
|
100
|
-
_ws_call(t["webSocketDebuggerUrl"], "Page.reload", 8.0)
|
|
101
|
-
revived += 1
|
|
102
|
-
except Exception:
|
|
103
|
-
pass
|
|
145
|
+
dead += 1
|
|
104
146
|
except Exception:
|
|
105
147
|
pass
|
|
106
148
|
|
|
107
149
|
out = {
|
|
108
|
-
"ready":
|
|
150
|
+
"ready": dead == 0, "mode": "raw_ws", "contexts": len(pages),
|
|
109
151
|
"elapsed_s": round(time.time() - t0, 2),
|
|
110
152
|
}
|
|
111
153
|
if revived:
|
|
112
154
|
out["revived"] = revived
|
|
155
|
+
if replaced:
|
|
156
|
+
out["replaced"] = replaced
|
|
157
|
+
if dead:
|
|
158
|
+
out["dead_pages"] = dead
|
|
159
|
+
out["error"] = "page_session_dead_unreplaceable"
|
|
113
160
|
print(json.dumps(out))
|
|
114
|
-
return 0
|
|
161
|
+
return 0 if dead == 0 else 1
|
|
115
162
|
except Exception as e:
|
|
116
163
|
print(json.dumps({
|
|
117
164
|
"ready": False, "mode": "raw_ws",
|
|
@@ -301,10 +301,19 @@ def _load_config():
|
|
|
301
301
|
def projects_json(lane=None, only_project=None):
|
|
302
302
|
"""The ALL_PROJECTS_JSON block: per-project drafting config as a JSON
|
|
303
303
|
string. lane='personal_brand' emits ONLY the persona project through the
|
|
304
|
-
whitelist; otherwise every project through the ops denylist.
|
|
304
|
+
whitelist; otherwise every ROUTABLE project through the ops denylist.
|
|
305
305
|
only_project (reddit lane: one project per draft call) narrows the
|
|
306
306
|
promotion output to that single project's entry. Returns '{}' on any
|
|
307
|
-
failure, mirroring the shell's `|| echo "{}"`.
|
|
307
|
+
failure, mirroring the shell's `|| echo "{}"`.
|
|
308
|
+
|
|
309
|
+
Routable (2026-09-23): the promotion roster excludes persona entries and
|
|
310
|
+
archived projects (enabled: false). ALL_PROJECTS_JSON doubles as the prep
|
|
311
|
+
step's PROJECT ROUTING target list ("...unless the thread content clearly
|
|
312
|
+
better fits another project"), so an unfiltered roster let a promotion
|
|
313
|
+
cycle re-route a candidate onto the persona while the personal_brand lane
|
|
314
|
+
was switched OFF (observed 2026-09-23: a promotion-lane card posted as
|
|
315
|
+
PersonalBrand), or onto an archived project. The persona is reachable
|
|
316
|
+
ONLY via the personal_brand lane's whitelist branch above."""
|
|
308
317
|
lane = lane if lane is not None else os.environ.get("S4L_ACTIVE_LANE", "")
|
|
309
318
|
try:
|
|
310
319
|
config = _load_config()
|
|
@@ -319,6 +328,10 @@ def projects_json(lane=None, only_project=None):
|
|
|
319
328
|
for p in projects:
|
|
320
329
|
if only_project and p.get("name") != only_project:
|
|
321
330
|
continue
|
|
331
|
+
if not only_project and (
|
|
332
|
+
p.get("persona") is True or p.get("enabled", True) is False
|
|
333
|
+
):
|
|
334
|
+
continue
|
|
322
335
|
out[p["name"]] = {k: v for k, v in p.items() if k not in _OPS_KEYS}
|
|
323
336
|
return json.dumps(out, indent=2)
|
|
324
337
|
except Exception:
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dump a visitor's cross-thread history as JSON for prompt injection.
|
|
3
|
+
|
|
4
|
+
Gives the web-chat agent memory of a returning visitor across ALL their
|
|
5
|
+
threads, so it never goes cold ("nothing like that on our side") on a known
|
|
6
|
+
user just because they opened a fresh thread. Reads:
|
|
7
|
+
|
|
8
|
+
GET /api/v1/web-chat/visitor?email=<email>&exclude=<thread>&limit=N
|
|
9
|
+
|
|
10
|
+
Used by skill/check-web-chats.sh alongside dump_web_chat_history.py:
|
|
11
|
+
|
|
12
|
+
$(python3 scripts/dump_visitor_history.py --email "$EMAIL" --exclude "$THREAD_ID")
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
{
|
|
16
|
+
"visitor_email": ..., "thread_count": N, "excluded_thread": ...,
|
|
17
|
+
"threads": [{ thread_id, project, page_url, created_at, last_message_at }, ...],
|
|
18
|
+
"recent_messages": [{ thread_id, sender, sender_name, text, created_at }, ...]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
On any error (no email, thread-only visitor, endpoint 404) it prints an empty
|
|
22
|
+
skeleton and exits 0, so the caller can always inline the output safely.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
from http_api import api_get
|
|
32
|
+
|
|
33
|
+
EMPTY = {"visitor_email": "", "thread_count": 0, "threads": [], "recent_messages": []}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
parser = argparse.ArgumentParser()
|
|
38
|
+
parser.add_argument("--email", required=True)
|
|
39
|
+
parser.add_argument("--exclude", default="", help="thread_id to leave out (the current one)")
|
|
40
|
+
parser.add_argument("--project", default="")
|
|
41
|
+
parser.add_argument("--limit", type=int, default=120)
|
|
42
|
+
args = parser.parse_args()
|
|
43
|
+
|
|
44
|
+
email = (args.email or "").strip()
|
|
45
|
+
if not email or "@" not in email:
|
|
46
|
+
print(json.dumps(EMPTY))
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
query = {"email": email, "limit": args.limit}
|
|
50
|
+
if args.exclude:
|
|
51
|
+
query["exclude"] = args.exclude
|
|
52
|
+
if args.project:
|
|
53
|
+
query["project"] = args.project
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
resp = api_get("/api/v1/web-chat/visitor", query=query, ok_on_404=True)
|
|
57
|
+
except Exception as e: # never let a memory lookup crash the spawn
|
|
58
|
+
print(json.dumps({**EMPTY, "visitor_email": email, "_error": str(e)}))
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
if resp.get("_not_found"):
|
|
62
|
+
print(json.dumps({**EMPTY, "visitor_email": email}))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
out = resp.get("data") or {}
|
|
66
|
+
if not out:
|
|
67
|
+
out = {**EMPTY, "visitor_email": email}
|
|
68
|
+
print(json.dumps(out, indent=2))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|