@m13v/s4l 1.7.12-rc.7 → 1.7.12-rc.9

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
@@ -3832,8 +3832,8 @@ async function autopilotLoaded() {
3832
3832
  // fires every minute, claims ONE job, runs the pipeline's own prompt as its
3833
3833
  // Claude turn, writes the result back, and stops.
3834
3834
  // ===========================================================================
3835
- const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 240s -> 900s (see QUEUE_WORKER_POLL_SECONDS); version bump forces the prompt refresh that carries the new --wait-seconds onto existing installs. v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
3836
- // v10 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
3835
+ const QUEUE_WORKER_PROMPT_VERSION = 10; // v10 (2026-10-29): poll window 900s -> 540s so one `next` call fits inside the Bash tool's 600s default timeout, self-reap disabled (S4L_WORKER_SELF_REAP=0 on every claude_job call), and the session ends by archiving itself instead of a SIGTERM. v9 (2026-07-17): poll window widened 240s -> 900s; version bump forces the prompt refresh that carries the new --wait-seconds onto existing installs. v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
3836
+ // v11 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
3837
3837
  // sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
3838
3838
  // within one continuous worker session) instead of drafting inline. Validated
3839
3839
  // via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
@@ -3842,10 +3842,19 @@ const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 2
3842
3842
  // notification) or the host kills the whole parent+child chain in 1-3 min.
3843
3843
  // Never live-fire tested against a real production job. Full design, what's
3844
3844
  // validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
3845
- // Bump this constant to 10 only once that plan is actually implemented.
3845
+ // Bump this constant to 11 only once that plan is actually implemented.
3846
3846
  const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
3847
3847
  // How long ONE `next --wait-seconds` call polls before giving up and exiting.
3848
- // 900s (15 min, per Matthew 2026-07-17, up from 240s): sits AT the single-
3848
+ // 540s (9 min, 2026-10-29, down from 900s): MUST stay below the Bash tool's
3849
+ // 600s default timeout. Measured on 2026-10-29 in Claude Desktop 4.29: the
3850
+ // 900s poll hit the 600s tool timeout on every empty cycle (81 of 106 runs
3851
+ // that day), the CLI then re-emitted a ~200KB prompt snapshot (a 7s
3852
+ // main-thread stall in the app), the poller kept running as an orphaned
3853
+ // background task for the remaining 300s, and its self-reap SIGTERM cost one
3854
+ // or two more 7s stalls plus an exit-143 re-attach. With the poll inside the
3855
+ // tool timeout the call returns normally, nothing is orphaned, and the worker
3856
+ // archives itself (step 5 of the prompt) at zero measured cost.
3857
+ // Earlier rationale (900s, per Matthew 2026-07-17, up from 240s): sits AT the single-
3849
3858
  // Bash-call survival ceiling verified live on 2026-07-06 (the host's ~90s
3850
3859
  // inactivity kill fires only on model silence, and one in-flight tool call
3851
3860
  // survived a full 900s probe). This covers the ~8min average real job
@@ -3861,7 +3870,7 @@ const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
3861
3870
  // poll window is legitimately still working, not a husk, and a too-tight
3862
3871
  // claim_grace would SIGTERM it mid-poll before it ever gets to claim.
3863
3872
  // (Bumped to 1020s alongside this change.)
3864
- const QUEUE_WORKER_POLL_SECONDS = 900;
3873
+ const QUEUE_WORKER_POLL_SECONDS = 540;
3865
3874
  // One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
3866
3875
  const QUEUE_WORKERS = [
3867
3876
  { taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
@@ -3983,8 +3992,9 @@ function queueWorkerBody(spec) {
3983
3992
  `The deterministic pipeline runs on this Mac. When it needs a Claude turn it ` +
3984
3993
  `drops a job on a local file queue. Your only job: pick up the next job, do ` +
3985
3994
  `EXACTLY what its prompt says, hand the result back. You do this with Bash, ` +
3986
- `Read, and Write, and NOTHING else. This run is unattended — reaching for any ` +
3987
- `other tool, or trying to "investigate", STALLS it forever.`,
3995
+ `Read, and Write, and NOTHING else (except the single archive call in step 5). ` +
3996
+ `This run is unattended — reaching for any other tool, or trying to ` +
3997
+ `"investigate", STALLS it forever.`,
3988
3998
  ``,
3989
3999
  `PACING — CRITICAL: this unattended session is terminated ~90 seconds after ` +
3990
4000
  `your LAST tool call (a host inactivity timeout). That clock only runs BETWEEN ` +
@@ -4000,11 +4010,16 @@ function queueWorkerBody(spec) {
4000
4010
  `1. Look for the next job. Run this EXACT Bash command and let it run to ` +
4001
4011
  `completion — it polls internally for up to ${Math.round(QUEUE_WORKER_POLL_SECONDS / 60)} ` +
4002
4012
  `minutes before giving up, so it may take a while to return. That is normal: ` +
4003
- `do NOT interrupt it and do NOT make any other tool call while it is running.`,
4004
- ` ${py} ${job} next --type any --prompt-file --wait-seconds ${QUEUE_WORKER_POLL_SECONDS} --state-dir ${sd}`,
4013
+ `do NOT interrupt it and do NOT make any other tool call while it is running. ` +
4014
+ `Pass the Bash tool's timeout parameter as 600000 (its 10-minute maximum) so ` +
4015
+ `the call is never moved to the background. If the result nevertheless says ` +
4016
+ `the command was moved to the background (this happens when the Mac sleeps ` +
4017
+ `mid-poll; the timeout is wall-clock), do NOT wait for it: call TaskStop with ` +
4018
+ `the ID it gives you, report "no jobs" in one line, and go to step 5.`,
4019
+ ` S4L_WORKER_SELF_REAP=0 ${py} ${job} next --type any --prompt-file --wait-seconds ${QUEUE_WORKER_POLL_SECONDS} --state-dir ${sd}`,
4005
4020
  ` It prints one line of JSON once it returns. If it prints "{}" (empty), no ` +
4006
4021
  `job showed up during the whole poll window — report "no jobs" in one line ` +
4007
- `and STOP. You are done.`,
4022
+ `and go to step 5.`,
4008
4023
  `2. Otherwise it prints {"job_id":"...","prompt_file":"...","schema_file":...}. ` +
4009
4024
  `Use the Read tool to read prompt_file; it is the complete, self-contained ` +
4010
4025
  `instruction the pipeline wrote for you. If the Read result says it is partial ` +
@@ -4014,20 +4029,28 @@ function queueWorkerBody(spec) {
4014
4029
  `ONLY that JSON object — no prose, no markdown, no code fences.`,
4015
4030
  `3. Submit it. Write your JSON object to ${outDir}/out-<job_id>.json using the ` +
4016
4031
  `Write tool (substitute the real job_id), then run this EXACT Bash command:`,
4017
- ` ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
4032
+ ` S4L_WORKER_SELF_REAP=0 ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
4018
4033
  ` If it reports the result was rejected (bad JSON / missing keys), fix your JSON ` +
4019
4034
  `and submit again — at most twice. If it still fails, run ` +
4020
- `\`${py} ${job} result --job <job_id> --error --state-dir ${sd}\` (type a one-line ` +
4021
- `reason, then Ctrl-D) and STOP.`,
4022
- `4. Report in ONE short line what you did, then STOP. Do NOT claim another job, ` +
4023
- `do NOT loop, do NOT read other files, do NOT call any other tool.`,
4035
+ `\`S4L_WORKER_SELF_REAP=0 ${py} ${job} result --job <job_id> --error --state-dir ${sd}\` (type a one-line ` +
4036
+ `reason, then Ctrl-D) and go to step 5.`,
4037
+ `4. Report in ONE short line what you did. Do NOT claim another job, ` +
4038
+ `do NOT loop, do NOT read other files.`,
4039
+ `5. End this session yourself. Call ToolSearch with the query ` +
4040
+ `"select:mcp__ccd_session_mgmt__archive_session", then call ` +
4041
+ `mcp__ccd_session_mgmt__archive_session with session_id "self" and reason ` +
4042
+ `"s4l worker done". That archive call must be your very last tool call; it ` +
4043
+ `ends the session cleanly (no SIGTERM, no idle wait). If the tool is not ` +
4044
+ `available, just STOP.`,
4024
4045
  ``,
4025
4046
  `HARD RULES: use ONLY the Bash tool (to run claude_job.py AND any persist ` +
4026
4047
  `commands the job's prompt explicitly gives you), the Read tool (the ` +
4027
4048
  `prompt/schema sidecar + the SKILL/config files the prompt names), and the ` +
4028
- `Write tool (the result file). NEVER post, reply, open a browser, or run any ` +
4029
- `command the prompt does not explicitly give you. An empty queue is the ` +
4030
- `NORMAL, expected case most minutes — it is success, not a problem to debug.`,
4049
+ `Write tool (the result file), plus TaskStop ONLY for a backgrounded poll, and ` +
4050
+ `ToolSearch and mcp__ccd_session_mgmt__archive_session ONLY as the final step 5. NEVER post, ` +
4051
+ `reply, open a browser, or run any command the prompt does not explicitly ` +
4052
+ `give you. An empty queue is the NORMAL, expected case most minutes — it is ` +
4053
+ `success, not a problem to debug.`,
4031
4054
  ].join("\n");
4032
4055
  }
4033
4056
  // Full canonical SKILL.md (frontmatter + body + version marker) the MCP writes
package/mcp/dist/setup.js CHANGED
@@ -45,6 +45,37 @@ export function configPath() {
45
45
  return p; // not created yet: return the intended path unresolved
46
46
  }
47
47
  }
48
+ // Atomic config.json write (2026-10-30). Never write config.json in place:
49
+ // writeFileSync truncates the file first, so a reader in that window (or an
50
+ // interrupted write) sees a torn file and every pipeline lane dies on it.
51
+ // Write a same-directory temp file, fsync, then rename over the target so
52
+ // readers only ever see the old or the new complete file. Keeps the
53
+ // timestamped .bak- copy the call sites used to make.
54
+ export function writeConfigAtomic(cfg) {
55
+ const cfgPath = configPath();
56
+ if (fs.existsSync(cfgPath)) {
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
58
+ fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
59
+ }
60
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
61
+ const tmp = `${cfgPath}.tmp-${process.pid}`;
62
+ try {
63
+ const fd = fs.openSync(tmp, "w");
64
+ try {
65
+ fs.writeSync(fd, JSON.stringify(cfg, null, 2) + "\n");
66
+ fs.fsyncSync(fd);
67
+ }
68
+ finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ fs.renameSync(tmp, cfgPath);
72
+ }
73
+ finally {
74
+ if (fs.existsSync(tmp))
75
+ fs.unlinkSync(tmp);
76
+ }
77
+ return cfgPath;
78
+ }
48
79
  // One-time (idempotent) migration to the state-dir home, run at server boot.
49
80
  // Cases:
50
81
  // - no state config, legacy is a REAL file -> move it up, leave a symlink
@@ -360,13 +391,7 @@ export function applySetup(input) {
360
391
  cfg.projects.push(entry);
361
392
  created = true;
362
393
  }
363
- const cfgPath = configPath();
364
- if (fs.existsSync(cfgPath)) {
365
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
366
- fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
367
- }
368
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
369
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
394
+ writeConfigAtomic(cfg);
370
395
  void sendStateSnapshot("config_write");
371
396
  if (!persona)
372
397
  recordManagedProject(input.name);
@@ -418,13 +443,7 @@ export function recordRedditAccount(username) {
418
443
  if (!reddit.login_method)
419
444
  reddit.login_method = "browser";
420
445
  try {
421
- const cfgPath = configPath();
422
- if (fs.existsSync(cfgPath)) {
423
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
424
- fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
425
- }
426
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
427
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
446
+ writeConfigAtomic(cfg);
428
447
  void sendStateSnapshot("config_write");
429
448
  return { written: true, detail: `accounts.reddit.username = ${clean}` };
430
449
  }
@@ -541,13 +560,7 @@ export function ensurePersonaProject(grounding) {
541
560
  name = PERSONA_DEFAULT_NAME;
542
561
  created = true;
543
562
  }
544
- const cfgPath = configPath();
545
- if (fs.existsSync(cfgPath)) {
546
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
547
- fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
548
- }
549
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
550
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
563
+ const cfgPath = writeConfigAtomic(cfg);
551
564
  // Raw dictation transcript -> persona_corpus.txt sidecar (NOT config.json).
552
565
  // Mirrors scripts/build_persona.py cmd_apply: config.json is inlined into many
553
566
  // prompts (ALL_PROJECTS_JSON), so a multi-KB corpus there would bloat every
@@ -598,13 +611,7 @@ export function ensureShortLinksDefault() {
598
611
  changed = true;
599
612
  }
600
613
  if (changed) {
601
- const cfgPath = configPath();
602
- if (fs.existsSync(cfgPath)) {
603
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
604
- fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
605
- }
606
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
607
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
614
+ writeConfigAtomic(cfg);
608
615
  void sendStateSnapshot("config_write");
609
616
  }
610
617
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.12-rc.7",
3
- "installedAt": "2026-09-24T20:17:26.520Z"
2
+ "version": "1.7.12-rc.9",
3
+ "installedAt": "2026-10-30T18:59:09.558Z"
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.7",
5
+ "version": "1.7.12-rc.9",
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": {
@@ -1690,6 +1690,30 @@ class S4LMenuBar(rumps.App):
1690
1690
  verification on such machines.
1691
1691
  """
1692
1692
  app_support = os.path.expanduser("~/Library/Application Support")
1693
+ # LIVE profile first (2026-09-28): on a multi-profile machine (account
1694
+ # rotator: Claude, Claude-mediar, Claude-mddy, ...) every profile keeps
1695
+ # its OWN copy of the extension, and "newest mtime" is simply the
1696
+ # profile that was updated most recently, not the one Claude is
1697
+ # running right now. The operator Mac sat on 1.7.7-rc.4 in the live
1698
+ # Claude-mediar profile for two months while every update landed in
1699
+ # Claude-mddy, and the updater kept reporting "already on latest".
1700
+ # Prefer the extension dir under a RUNNING Claude's --user-data-dir
1701
+ # (default profile == plain "Claude"); fall back to the newest-mtime
1702
+ # scan only when no Claude instance is running. Mirrors
1703
+ # scripts/s4l_box_update.sh; keep the two in sync.
1704
+ for udd in S4LMenuBar._claude_user_data_dirs():
1705
+ root = os.path.join(udd or os.path.join(app_support, "Claude"),
1706
+ "Claude Extensions")
1707
+ try:
1708
+ names = sorted(os.listdir(root))
1709
+ except OSError:
1710
+ continue
1711
+ for name in names:
1712
+ if not name.endswith("social-autoposter"):
1713
+ continue
1714
+ d = os.path.join(root, name)
1715
+ if os.path.exists(os.path.join(d, "manifest.json")):
1716
+ return d
1693
1717
  best, best_mtime = None, -1.0
1694
1718
  for root in glob.glob(os.path.join(app_support, "Claude*", "Claude Extensions")):
1695
1719
  try:
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.12-rc.7",
3
+ "version": "1.7.12-rc.9",
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.7",
3
+ "version": "1.7.12-rc.9",
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",
@@ -41,6 +41,23 @@
41
41
  "!scripts/_gsc_roi_query.py",
42
42
  "!scripts/batch_send_dms.py",
43
43
  "!scripts/finalize_post_*.py",
44
+ "!scripts/insert_[0-9]*.py",
45
+ "!scripts/insert[0-9]*.py",
46
+ "!scripts/ins[0-9]*.py",
47
+ "!scripts/tlh_pick_*.py",
48
+ "!scripts/tlh_freshness_*.py",
49
+ "!scripts/tlh_fresh.py",
50
+ "!scripts/tlh_stale.py",
51
+ "!scripts/fresh[0-9]*.py",
52
+ "!scripts/hist[0-9]*.py",
53
+ "!scripts/q[0-9]*.py",
54
+ "!scripts/show[0-9]*.py",
55
+ "!scripts/stale[0-9]*.py",
56
+ "!scripts/stale_[0-9]*.py",
57
+ "!scripts/stale_classes_*.py",
58
+ "!scripts/stale_clips_*.py",
59
+ "!scripts/r[0-9]*.py",
60
+ "!scripts/w[0-9]*.py",
44
61
  "!scripts/insert_li_notifs.py",
45
62
  "!scripts/li_notif_scan_process.py",
46
63
  "!scripts/phase_d_edit.py",
@@ -0,0 +1,8 @@
1
+ import os,psycopg2
2
+ from dotenv import load_dotenv
3
+ load_dotenv(os.path.expanduser('~/social-autoposter/.env'))
4
+ c=psycopg2.connect(os.environ['DATABASE_URL']).cursor()
5
+ c.execute("select distinct metadata->>'theme_angle' from media_posts")
6
+ print(' '.join(sorted(r[0] or '' for r in c.fetchall())))
7
+ c.execute("select distinct metadata->>'engagement_style' from media_posts")
8
+ print([r[0] for r in c.fetchall()])
@@ -1303,38 +1303,22 @@ def cmd_provider(ns) -> int:
1303
1303
  # next (consumer side, run by a scheduled task) #
1304
1304
  # --------------------------------------------------------------------------- #
1305
1305
  def _agent_session_pid():
1306
- """Best-effort: the Claude agent-mode SESSION pid running THIS worker — the
1307
- exact process the stale-session reaper (reap_stale_claude_sessions.py) would
1308
- target. We climb our own process tree to the ancestor whose cmd carries the
1309
- reaper's worker signature ('claude-code/' + 'local-agent-mode-sessions') and
1310
- return its pid, so the claim can be stamped with it and the reaper can SPARE
1311
- that session for the whole drafting turn (instead of SIGTERMing it at the short
1312
- grace window — the 2026-06-29 draft-kill regression). None if not identifiable;
1313
- the reaper then falls back to its newest-spare heuristic.
1314
- """
1306
+ """Best-effort: pid of the Claude agent-mode SESSION running THIS worker —
1307
+ the exact process the stale-session reaper (reap_stale_claude_sessions.py)
1308
+ would target. The claim is stamped with it
1309
+ so the reaper (a) SPARES that session for the whole drafting turn (the
1310
+ 2026-06-29 draft-kill regression) and (b) re-queues the job once that
1311
+ session is dead (release_stale_claims, ~2 min). Shares _find_own_session's
1312
+ signature: the old private copy matched 'local-agent-mode-sessions', which
1313
+ current Desktop builds no longer put on the cmdline, so every claim went
1314
+ unstamped and a claim whose session died stranded its producer for the full
1315
+ ~30 min timeout (2026-10-30: Desktop relaunched a minute after a claim).
1316
+ None if not identifiable."""
1315
1317
  try:
1316
- out = subprocess.run(
1317
- ["/bin/ps", "-axo", "pid=,ppid=,command="],
1318
- capture_output=True, text=True, timeout=10,
1319
- ).stdout
1320
- info = {}
1321
- for line in out.splitlines():
1322
- m = re.match(r"\s*(\d+)\s+(\d+)\s+(.*)$", line)
1323
- if m:
1324
- info[int(m.group(1))] = (int(m.group(2)), m.group(3))
1325
- pid = os.getpid()
1326
- for _ in range(16): # bounded climb up the tree
1327
- ent = info.get(pid)
1328
- if not ent or ent[0] <= 1:
1329
- break
1330
- ppid = ent[0]
1331
- pcmd = info.get(ppid, (0, ""))[1]
1332
- if ("claude-code/" in pcmd) and ("local-agent-mode-sessions" in pcmd):
1333
- return ppid
1334
- pid = ppid
1318
+ found = _find_own_session(_ps_pid_map())
1335
1319
  except Exception:
1336
1320
  return None
1337
- return None
1321
+ return found[0] if found else None
1338
1322
 
1339
1323
 
1340
1324
  def _attempt_claim(ns, qtype: str) -> bool:
package/scripts/config.py CHANGED
@@ -27,15 +27,22 @@ Resolution order for the repo root (single source of truth):
27
27
  $S4L_CONFIG_PATH overrides the config file location outright (some installs
28
28
  pin the operator config, e.g. ~/s4l/config.json, independent of the code dir).
29
29
 
30
- Do NOT add write helpers here. Config writes go through the MCP server's
31
- project_config tool (it owns validation and the state snapshot); pipeline
32
- scripts are readers.
30
+ Config writes: user-facing edits go through the MCP server's project_config
31
+ tool (it owns validation and the state snapshot). The few pipeline scripts
32
+ that must persist runtime state into config.json (subreddit bans) MUST use
33
+ update_config() below and nothing else: never `open(config, "w")` +
34
+ json.dump. That in-place pattern truncates the file to 0 bytes and streams it
35
+ back, so any interruption leaves a torn prefix that breaks every reader
36
+ (2026-09-06, 09-30, 10-22, 10-29 outages; the 10-29 one stopped all X
37
+ drafting for ~23h).
33
38
  """
34
39
 
35
40
  from __future__ import annotations
36
41
 
42
+ import fcntl
37
43
  import json
38
44
  import os
45
+ import sys
39
46
 
40
47
  _CACHE: dict | None = None
41
48
  _CACHE_PATH: str | None = None
@@ -98,6 +105,47 @@ def load_config(fresh: bool = False) -> dict:
98
105
  return _CACHE
99
106
 
100
107
 
108
+ def update_config(mutate, path: str | None = None):
109
+ """Locked, atomic read-modify-write of config.json.
110
+
111
+ `mutate(cfg)` edits the dict in place and returns a truthy value when it
112
+ changed something (nothing is written otherwise); its return value is
113
+ passed back to the caller. The whole read->mutate->write span holds the
114
+ same fcntl lock on <config>.lock that learned_preferences.py takes, so
115
+ concurrent writers cannot erase each other's edits. The new content goes
116
+ to a temp file in the same directory and is swapped in with os.replace,
117
+ so readers only ever see the complete old file or the complete new one;
118
+ a writer killed mid-write leaves a stray temp file, never a torn config.
119
+
120
+ Raises if config.json is missing or unparseable: callers must not
121
+ "repair" a broken config by writing a partial dict over it.
122
+ """
123
+ global _CACHE, _CACHE_PATH, _CACHE_MTIME
124
+ path = path or config_path()
125
+ with open(path + ".lock", "w") as lock_f:
126
+ fcntl.flock(lock_f, fcntl.LOCK_EX)
127
+ with open(path) as f:
128
+ cfg = json.load(f)
129
+ result = mutate(cfg)
130
+ if not result:
131
+ return result
132
+ tmp = f"{path}.tmp-{os.getpid()}"
133
+ try:
134
+ with open(tmp, "w") as f:
135
+ f.write(json.dumps(cfg, indent=2) + "\n")
136
+ f.flush()
137
+ os.fsync(f.fileno())
138
+ os.replace(tmp, path)
139
+ finally:
140
+ if os.path.exists(tmp):
141
+ os.unlink(tmp)
142
+ # One attribution line per write, so the next incident names its writer.
143
+ caller = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else "?"
144
+ print(f"[config] saved {path} by {caller} pid={os.getpid()}", file=sys.stderr)
145
+ _CACHE, _CACHE_PATH, _CACHE_MTIME = None, None, None
146
+ return result
147
+
148
+
101
149
  def get(path: str, default=None):
102
150
  """Dotted-path lookup into config.json: get("accounts.twitter.handle").
103
151
  A single-segment path reads a top-level key. Lists are not traversed;