transitions-refine 0.3.13 → 0.3.15

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.
@@ -54,6 +54,19 @@ The relay reports the agent as "available" for ~120s after your last poll, so
54
54
  short pauses keep you live. A successful job always resets idle, so an active
55
55
  session never backs off.
56
56
 
57
+ 0. **Announce yourself once, before the first poll.** The relay keeps a *sticky*
58
+ Stop latch: after a panel **Stop** (or the idle auto-stop) it answers every
59
+ `GET /jobs/next` with `{"stop": true}` until a new agent explicitly resumes —
60
+ so a stopped session can't silently come back. Clear the latch a single time
61
+ at startup, then begin polling:
62
+
63
+ ```bash
64
+ curl -s -X POST http://localhost:7331/poller/start
65
+ ```
66
+
67
+ Do **not** call this again mid-loop (it would defeat a user's Stop). Only on a
68
+ fresh `/refine live`.
69
+
57
70
  1. **Claim the next job (long-poll).** This call blocks up to ~25s, then returns.
58
71
 
59
72
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "transitions-refine",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Live, agent-driven Refine panel for CSS/Motion transitions — injects a timeline + Refine UI and runs transitions.dev suggestions via your coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -446,6 +446,10 @@ async function pollOnce() {
446
446
 
447
447
  async function loop() {
448
448
  log(`live refine loop → ${RELAY} (workspace: ${WORKSPACE})`);
449
+ // Announce ourselves so the relay clears any prior Stop latch — otherwise a
450
+ // relay that's still "stopped" from a previous panel Stop would answer our
451
+ // first poll with {stop:true} and we'd exit immediately.
452
+ try { await post("/poller/start", {}); } catch {}
449
453
  // eslint-disable-next-line no-constant-condition
450
454
  while (true) {
451
455
  try {
package/server/relay.mjs CHANGED
@@ -25,7 +25,7 @@
25
25
  import { createServer } from "node:http";
26
26
  import { randomUUID } from "node:crypto";
27
27
  import { spawn } from "node:child_process";
28
- import { existsSync } from "node:fs";
28
+ import { existsSync, readFileSync } from "node:fs";
29
29
  import { homedir } from "node:os";
30
30
  import { delimiter, join } from "node:path";
31
31
  import { refineTimings, DURATION_TOKENS, SCALE_TOKENS, BLUR_TOKENS, SMOOTH_OUT } from "./motion-tokens.mjs";
@@ -33,6 +33,12 @@ import { buildInjectModule } from "./inject.mjs";
33
33
 
34
34
  const PORT = Number(process.env.REFINE_RELAY_PORT) || 7331;
35
35
  const AUTO = process.env.REFINE_AUTO !== "0";
36
+ // Own package version, surfaced on /health so you can verify which relay build is
37
+ // actually running (npx caches — a stale relay is the usual "fix didn't work").
38
+ let PKG_VERSION = "0.0.0";
39
+ try {
40
+ PKG_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || PKG_VERSION;
41
+ } catch {}
36
42
 
37
43
  // A bare `cursor-agent` goes interactive: it prints "⚠ Workspace Trust Required"
38
44
  // and exits 1, so every headless refine/scan/apply job fails. Force the headless
@@ -91,13 +97,13 @@ const PENDING_TIMEOUT_MS = Number(process.env.REFINE_PENDING_TIMEOUT_MS) || 1200
91
97
  // to stop (it returns {stop:true} from /jobs/next). 0 disables. Only the chat
92
98
  // loop is affected — a wired REFINE_AGENT_CMD never polls /jobs/next.
93
99
  const POLLER_IDLE_STOP_MS = Number(process.env.REFINE_POLLER_IDLE_STOP_MS) || 600000;
94
- // After a Stop (manual or idle), hold the poller "stopped" for this long: every
95
- // GET /jobs/next keeps getting {stop:true} and /health reports the poller
96
- // inactive. Without it, a single straggler poll fired right after the stop
97
- // (an agent mid-backoff, or one that batches a poll before exiting) re-registers
98
- // as live and the panel flips "Live" back on a few seconds later. A genuine
99
- // re-run of `/refine live` after the window resumes normally.
100
- const STOP_GRACE_MS = Number(process.env.REFINE_STOP_GRACE_MS) || 5000;
100
+ // While the Stop latch is held, a genuinely new `/refine live` session can resume
101
+ // itself implicitly: only after polling has been *quiet* this long (the old loop
102
+ // actually died) do we treat the next poll as a fresh session and auto-resume.
103
+ // Must exceed a looping agent's poll gap, so an agent that ignores Stop and keeps
104
+ // polling can never clear the latch it just keeps getting {stop:true}. (An
105
+ // updated agent also resumes explicitly via POST /poller/start, no wait needed.)
106
+ const STOP_QUIET_MS = Number(process.env.REFINE_STOP_QUIET_MS) || 15000;
101
107
 
102
108
  /** @type {Map<string, Job>} */
103
109
  const jobs = new Map();
@@ -106,10 +112,17 @@ const now = () => Date.now();
106
112
  // When did a `/refine live` agent last poll? Used to know if LLM mode can be
107
113
  // served by a live editor agent (vs. needing REFINE_AGENT_CMD).
108
114
  let lastPollAt = 0;
109
- // While now() < stoppedUntil the relay holds a just-issued Stop (see STOP_GRACE_MS):
110
- // the poller reports inactive and every /jobs/next answers {stop:true}.
111
- let stoppedUntil = 0;
112
- const pollerActive = () => now() >= stoppedUntil && now() - lastPollAt < POLLER_TTL_MS;
115
+ // Stop latch. Set by POST /poller/stop (and idle auto-stop); cleared ONLY by an
116
+ // explicit POST /poller/start (a fresh `/refine live` / agent loop announcing
117
+ // itself). While latched the poller reports inactive and every GET /jobs/next
118
+ // answers {stop:true}, so a loop that ignores the stop or keeps polling /
119
+ // re-polls — can never silently revive the session. This is what makes the
120
+ // panel's Stop button stick instead of flipping "Live" back on seconds later.
121
+ let pollerStopped = false;
122
+ // When did a poll last arrive *while latched*? Used to detect that the old loop
123
+ // went quiet so a genuinely new session (or re-run) can auto-resume.
124
+ let lastStoppedPollAt = 0;
125
+ const pollerActive = () => !pollerStopped && now() - lastPollAt < POLLER_TTL_MS;
113
126
  const llmAvailable = () => Boolean(AGENT_CMD) || pollerActive();
114
127
 
115
128
  // Stop signal for the in-chat `/refine live` loop. Set by POST /poller/stop
@@ -599,9 +612,11 @@ const server = createServer(async (req, res) => {
599
612
  if (method === "GET" && path === "/health") {
600
613
  return send(res, 200, {
601
614
  ok: true,
615
+ version: PKG_VERSION,
602
616
  auto: AUTO,
603
617
  llmAvailable: llmAvailable(),
604
618
  pollerActive: pollerActive(),
619
+ pollerStopped,
605
620
  agentCmd: Boolean(AGENT_CMD),
606
621
  cliInstalled: cursorCliInstalled(),
607
622
  jobs: jobs.size,
@@ -663,32 +678,43 @@ const server = createServer(async (req, res) => {
663
678
 
664
679
  // GET /jobs/next — long-poll claimed by a `/refine live` agent (LLM jobs).
665
680
  if (method === "GET" && path === "/jobs/next") {
666
- // Stop-grace window: a Stop was just issued, so keep telling every poller to
667
- // exit and don't count these polls as live (we deliberately do NOT update
668
- // lastPollAt here). This stops a straggler poll from reviving the session.
669
- // A pending job still wins so we never drop real work on the stop edge.
670
- if (now() < stoppedUntil && !nextPendingLlm()) {
671
- return send(res, 200, { stop: true });
681
+ // Stopped latch: a Stop was issued and not yet resumed. Keep telling every
682
+ // poller to exit and report inactive until either an explicit POST
683
+ // /poller/start, or polling goes quiet for STOP_QUIET_MS (the old loop died)
684
+ // and a new poll arrives only then is it a genuinely fresh session.
685
+ // A pending job always wins so we never drop real work on the edge.
686
+ if (pollerStopped && !nextPendingLlm()) {
687
+ if (lastStoppedPollAt && now() - lastStoppedPollAt >= STOP_QUIET_MS) {
688
+ // Quiet gap → the previous loop is gone. This poll is a fresh session.
689
+ pollerStopped = false;
690
+ lastStoppedPollAt = 0;
691
+ // fall through to normal handling below (counts as live again)
692
+ } else {
693
+ lastStoppedPollAt = now(); // a poller is still hammering us → hold the latch
694
+ return send(res, 200, { stop: true });
695
+ }
672
696
  }
673
697
  lastPollAt = now();
674
698
  if (!lastJobAt) lastJobAt = now(); // seed idle window on the loop's first poll
675
- // Stop signal (manual Stop button or idle auto-stop) → tell the loop to exit.
676
- // A pending job always wins so we never drop real work on the stop edge.
699
+ // Idle auto-stop (or a leftover stopRequested) → latch stopped + tell the
700
+ // loop to exit. A pending job always wins so we never drop real work.
677
701
  if ((stopRequested || (POLLER_IDLE_STOP_MS && now() - lastJobAt >= POLLER_IDLE_STOP_MS)) && !nextPendingLlm()) {
678
702
  stopRequested = false;
703
+ pollerStopped = true; // latch until /poller/start or a quiet gap
704
+ lastStoppedPollAt = 0;
679
705
  lastJobAt = 0;
680
706
  lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
681
- stoppedUntil = now() + STOP_GRACE_MS; // hold the stop so re-polls can't revive it
682
707
  return send(res, 200, { stop: true });
683
708
  }
684
709
  const deadline = now() + LONGPOLL_MS;
685
710
  const attempt = () => {
686
711
  if (res.writableEnded) return;
687
- if ((stopRequested || now() < stoppedUntil) && !nextPendingLlm()) {
712
+ if (stopRequested && !nextPendingLlm()) {
688
713
  stopRequested = false;
714
+ pollerStopped = true; // latch until /poller/start or a quiet gap
715
+ lastStoppedPollAt = 0;
689
716
  lastJobAt = 0;
690
717
  lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
691
- stoppedUntil = now() + STOP_GRACE_MS; // hold the stop so re-polls can't revive it
692
718
  return send(res, 200, { stop: true });
693
719
  }
694
720
  const job = nextPendingLlm();
@@ -709,11 +735,25 @@ const server = createServer(async (req, res) => {
709
735
  if (method === "POST" && path === "/poller/stop") {
710
736
  const wasActive = now() - lastPollAt < POLLER_TTL_MS;
711
737
  stopRequested = true;
738
+ pollerStopped = true; // latch: stays stopped until /poller/start or a quiet gap
739
+ lastStoppedPollAt = 0;
712
740
  lastPollAt = 0; // report inactive immediately; a straggler poll won't revive it
713
- stoppedUntil = now() + STOP_GRACE_MS; // hold the stop through the grace window
714
741
  return send(res, 200, { ok: true, stopping: wasActive });
715
742
  }
716
743
 
744
+ // POST /poller/start — a fresh `/refine live` agent (or loop) announces itself
745
+ // and clears the Stop latch so its polls count as live again. Without this an
746
+ // explicit re-run would have to wait out the quiet window. Call it once at loop
747
+ // startup, before the first GET /jobs/next.
748
+ if (method === "POST" && path === "/poller/start") {
749
+ pollerStopped = false;
750
+ lastStoppedPollAt = 0;
751
+ stopRequested = false;
752
+ lastJobAt = now();
753
+ lastPollAt = now();
754
+ return send(res, 200, { ok: true });
755
+ }
756
+
717
757
  const m = path.match(/^\/jobs\/([^/]+)(?:\/(status|result|error))?$/);
718
758
  if (m) {
719
759
  const job = jobs.get(m[1]);