@m13v/s4l 1.7.12-rc.5 → 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 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 && isPidAlive(existing.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(() => void sendHeartbeat("interval"), 15 * 60_000);
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(() => void sendStateSnapshot("interval"), 15 * 60_000);
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 += s;
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 += s;
183
+ stderr = capTail(stderr + s);
147
184
  errBuf = pump(s, "stderr", errBuf);
148
185
  sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
149
186
  });
150
- child.on("close", (code) => {
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
  });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.12-rc.5",
3
- "installedAt": "2026-09-01T23:32:18.166Z"
2
+ "version": "1.7.12-rc.6",
3
+ "installedAt": "2026-09-24T20:11:16.353Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.12-rc.5",
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.5",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.12-rc.5",
3
+ "version": "1.7.12-rc.6",
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",
@@ -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()
@@ -270,19 +270,32 @@ Read the FULL thread above. There is NO cap on how many times we can reply to a
270
270
 
271
271
  CHECK ESCALATION FIRST. Escalation hands the thread to the human who owns
272
272
  this account; they answer by email and their answer is posted as the
273
- comment. ESCALATE (output action=escalate with a one-line reason) when any
274
- of these is true:
275
- - The replier directly addresses OUR author (by @mention or unmistakable
276
- context) with a question about something WE said, e.g. "@{our_username}
277
- could you expand on what you mean?". A personal question to us deserves
278
- the human's own answer, not a generated one.
279
- - The replier reports a bug, breakage, or defect in one of OUR products
273
+ comment. Escalation is EXPENSIVE (a human inbox interrupt), so investigate
274
+ the thread and decide case by case: escalate ONLY when a well-written
275
+ generated reply cannot adequately serve because the comment needs the
276
+ human's OWN voice, knowledge, or commitment. That means one of:
277
+ - They ask our author something only the human can truthfully answer:
278
+ personal history or experience ("how did you come across this
279
+ project?"), intentions, commitments, or private context.
280
+ - They directly ask our author to explain or justify something WE said
281
+ and the subtext is confusion or pushback about our comment ("@{our_username}
282
+ could you expand on what you mean?", "who is that comment for?").
283
+ Getting the clarification wrong would embarrass us; the human answers.
284
+ - They report a bug, breakage, or defect in one of OUR products
280
285
  (anything in config.json). Bug reports need the maker's eyes.
281
- - The replier is the repo OWNER (or an obvious maintainer) engaging
282
- seriously with our comment. Maintainer conversations are relationships
283
- the human should hold personally.
284
- When a comment fits both ESCALATE and REPLY, escalate. Do not draft a
285
- reply text for an escalated comment.
286
+ - A genuine relationship moment with the repo owner/maintainer: strong
287
+ praise of our contribution, an invitation to collaborate or contribute,
288
+ or an explicit request for a decision from us.
289
+ NOT grounds for escalation on their own (2026-09-04 recalibration; the
290
+ blanket "owner engaging seriously" rule flooded the human's inbox):
291
+ - The repo owner merely engaging seriously: agreeing with us, clarifying
292
+ scope, posting design decisions, or announcing a merge/close. Write a
293
+ substantive reply (or skip if concluded) instead.
294
+ - Thread activity that is weeks old: a personal email answer to a stale
295
+ comment lands worse than a normal reply. Prefer reply or skip.
296
+ When you do escalate, the reason line MUST say what the human uniquely
297
+ adds that a generated reply cannot. Do not draft a reply text for an
298
+ escalated comment.
286
299
 
287
300
  Otherwise, DEFAULT TO REPLY when you have substance. Lean toward engagement, not silence.
288
301
 
@@ -189,20 +189,27 @@ def load_project_topics(project_name: str,
189
189
 
190
190
 
191
191
  def project_universe_strings(project_name: str) -> set[str]:
192
- """Full active universe for the project from project_search_topics.
192
+ """Full universe for the project from project_search_topics, EVERY status.
193
193
 
194
194
  Read from /api/v1/project-search-topics for the freshest read at
195
195
  invent time. Lowercased for case-insensitive matching against
196
196
  proposals.
197
+
198
+ status=all (2026-09-22): the dedup/avoid set must include paused and
199
+ excluded topics, not just active ones. A paused topic absent from the
200
+ universe would be re-proposed as "new" and the validate_proposals upsert
201
+ (status='active') would silently resurrect it, undoing a deliberate
202
+ repositioning. Paused topics in the prompt's covered list are equally
203
+ correct: the model must not re-propose them.
197
204
  """
198
205
  try:
199
206
  resp = api_get(
200
207
  "/api/v1/project-search-topics",
201
- query={"project": project_name, "status": "active"},
208
+ query={"project": project_name, "status": "all"},
202
209
  )
203
210
  except Exception as exc:
204
211
  raise SystemExit(
205
- f"could not fetch active universe for project={project_name!r}: {exc}"
212
+ f"could not fetch topic universe for project={project_name!r}: {exc}"
206
213
  ) from exc
207
214
  rows = ((resp or {}).get("data") or {}).get("topics") or []
208
215
  return {(r.get("topic") or "").strip().lower() for r in rows if r.get("topic")}
@@ -215,6 +215,10 @@ def build_candidates(config):
215
215
  thread_blocked = load_thread_blocked_subs(config)
216
216
  candidates = []
217
217
  for p in config.get("projects", []):
218
+ # Project-level archive flag wins over the per-lane flag: an archived
219
+ # project (enabled: false) must not post from ANY lane.
220
+ if p.get("enabled", True) is False:
221
+ continue
218
222
  t = p.get("threads") or {}
219
223
  if not t.get("enabled"):
220
224
  continue
@@ -97,6 +97,10 @@ def angle_recency(project_recents, angle_text):
97
97
  def build_candidates(config, project_recents):
98
98
  candidates = [] # (project_dict, angle_text, floor_days, last_used_days_ago_or_None)
99
99
  for p in config.get("projects", []):
100
+ # Project-level archive flag wins over the per-lane flag: an archived
101
+ # project (enabled: false) must not post from ANY lane.
102
+ if p.get("enabled", True) is False:
103
+ continue
100
104
  tt = p.get("twitter_threads") or {}
101
105
  if not tt.get("enabled"):
102
106
  continue
@@ -156,13 +156,69 @@ def _bootstrap_from_config(name: str) -> int:
156
156
  return seeded
157
157
 
158
158
 
159
+ def _sync_new_config_topics(name: str, active: List[str]) -> List[str]:
160
+ """Additive config->DB sync (2026-09-22). Before this, config.json
161
+ search_topics only reached the DB on the zero-active-rows first bootstrap;
162
+ topics ADDED to config later were silently ignored forever (this is how
163
+ Sundial's 09-11 math seeds never ran). Now any config topic the DB has
164
+ NEVER seen (in any status) is inserted as source='seed'/active on read.
165
+
166
+ Paused/excluded rows stay authoritative: a topic that still lingers in
167
+ config but was deliberately paused in the DB is known (status=all check)
168
+ and is NOT re-inserted or reactivated. Costs nothing in steady state: the
169
+ status=all read fires only when config carries a topic missing from the
170
+ active set. Returns the newly inserted topics ([] on any failure)."""
171
+ if os.environ.get("S4L_NO_TOPIC_AUTOSEED") == "1":
172
+ return []
173
+ missing = [t for t in _config_topics_for(name) if t not in set(active)]
174
+ if not missing:
175
+ return []
176
+ try:
177
+ from http_api import api_get, api_post
178
+ resp = api_get(
179
+ "/api/v1/project-search-topics",
180
+ query={"project": name, "status": "all"},
181
+ )
182
+ except Exception as e:
183
+ sys.stderr.write(
184
+ f"[project_topics] config-sync skipped project={name!r}: {e}\n"
185
+ )
186
+ return []
187
+ rows = ((resp or {}).get("data") or {}).get("topics") or []
188
+ known = {(r.get("topic") or "").strip() for r in rows}
189
+ inserted: List[str] = []
190
+ for topic in missing:
191
+ if topic in known:
192
+ continue # exists paused/excluded (or raced in): leave it alone
193
+ try:
194
+ api_post(
195
+ "/api/v1/project-search-topics",
196
+ body={"project": name, "topic": topic,
197
+ "source": "seed", "status": "active",
198
+ "notes": "config->DB additive sync"},
199
+ )
200
+ inserted.append(topic)
201
+ except Exception as e:
202
+ sys.stderr.write(
203
+ f"[project_topics] config-sync FAILED project={name!r} "
204
+ f"topic={topic!r}: {e}\n"
205
+ )
206
+ if inserted:
207
+ sys.stderr.write(
208
+ f"[project_topics] config-sync added {len(inserted)} new topic(s) "
209
+ f"for project={name!r}: {inserted}\n"
210
+ )
211
+ return inserted
212
+
213
+
159
214
  def topics_for_project(name: str) -> List[str]:
160
215
  """Active topics for one project (DB-backed, process-cached, self-healing).
161
216
 
162
217
  On the first read where the DB has no active topics but config.json carries
163
218
  a search_topics[] seed, the seed is mirrored into the DB once and re-read,
164
219
  so adding a project to config.json is enough to make it run. After that the
165
- DB is the single living source of truth."""
220
+ DB is the living source of truth for topic STATUS (pausing/excluding), while
221
+ topics newly ADDED to config keep flowing in via _sync_new_config_topics."""
166
222
  if not name:
167
223
  return []
168
224
  key = name.strip()
@@ -173,6 +229,8 @@ def topics_for_project(name: str) -> List[str]:
173
229
  _BOOTSTRAP_ATTEMPTED.add(key)
174
230
  if _bootstrap_from_config(key) > 0:
175
231
  topics = _fetch_active_topics(key)
232
+ else:
233
+ topics = topics + _sync_new_config_topics(key, topics)
176
234
  _CACHE[key] = topics
177
235
  return topics
178
236