transitions-refine 0.3.19 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +74 -119
- package/demo.html +59 -14
- package/package.json +1 -1
- package/server/agent-resolve.mjs +141 -0
- package/server/relay.mjs +73 -61
package/bin/cli.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, realpathSyn
|
|
|
23
23
|
import { dirname, join, resolve } from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
|
-
import { get as httpGet } from "node:http";
|
|
26
|
+
import { get as httpGet, request as httpRequest } from "node:http";
|
|
27
|
+
import { AGENTS, findBin, resolveAgentCmd } from "../server/agent-resolve.mjs";
|
|
27
28
|
|
|
28
29
|
const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
29
30
|
const CWD = process.cwd();
|
|
@@ -121,82 +122,9 @@ function dropSkill(name) {
|
|
|
121
122
|
// prompt, stdout = JSON). To bill against the account the user ALREADY pays for,
|
|
122
123
|
// we detect which agent is hosting this run and wire ITS CLI: a Claude Code user
|
|
123
124
|
// gets `claude`, a Codex user gets `codex`, a Cursor user gets `cursor-agent`.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const AGENTS = [
|
|
129
|
-
{
|
|
130
|
-
key: "cursor",
|
|
131
|
-
label: "Cursor",
|
|
132
|
-
// Cursor's agent terminal exports CURSOR_AGENT.
|
|
133
|
-
host: () => Boolean(process.env.CURSOR_AGENT),
|
|
134
|
-
bins: [
|
|
135
|
-
"cursor-agent",
|
|
136
|
-
join(HOME, ".local/bin/cursor-agent"),
|
|
137
|
-
join(HOME, ".cursor/bin/cursor-agent"),
|
|
138
|
-
],
|
|
139
|
-
// -p = headless/stdin, --force = auto-allow tool calls. The relay also
|
|
140
|
-
// auto-appends -p/--trust/--force for cursor-agent; we wire them up front so
|
|
141
|
-
// the printed command is the real one.
|
|
142
|
-
cmd: (bin) => `${bin} -p --force`,
|
|
143
|
-
canInstall: true,
|
|
144
|
-
auth: "run `cursor-agent` once to log in, or set CURSOR_API_KEY",
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
key: "claude",
|
|
148
|
-
label: "Claude Code",
|
|
149
|
-
// Claude Code exports CLAUDECODE=1 (+ CLAUDE_CODE_*) in its tools/terminals.
|
|
150
|
-
host: () => Boolean(process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT),
|
|
151
|
-
bins: [
|
|
152
|
-
"claude",
|
|
153
|
-
join(HOME, ".claude/local/claude"),
|
|
154
|
-
join(HOME, ".local/bin/claude"),
|
|
155
|
-
],
|
|
156
|
-
// -p = headless print (prompt on stdin); skip-permissions so apply jobs can
|
|
157
|
-
// edit files without an interactive approval prompt.
|
|
158
|
-
cmd: (bin) => `${bin} -p --dangerously-skip-permissions`,
|
|
159
|
-
canInstall: false,
|
|
160
|
-
auth: "run `claude` once to sign in",
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
key: "codex",
|
|
164
|
-
label: "Codex",
|
|
165
|
-
// Codex exec exports CODEX_SANDBOX (+ CODEX_* friends) in its sandbox.
|
|
166
|
-
host: () => Boolean(process.env.CODEX_SANDBOX) || envHasPrefix("CODEX_"),
|
|
167
|
-
bins: ["codex", join(HOME, ".local/bin/codex")],
|
|
168
|
-
// `codex exec -` reads the prompt on stdin; workspace-write so apply jobs can
|
|
169
|
-
// edit files; skip-git-repo-check so a non-git project root doesn't error out.
|
|
170
|
-
cmd: (bin) => `${bin} exec --sandbox workspace-write --skip-git-repo-check -`,
|
|
171
|
-
canInstall: false,
|
|
172
|
-
auth: "run `codex` once to sign in, or set CODEX_API_KEY",
|
|
173
|
-
},
|
|
174
|
-
];
|
|
175
|
-
|
|
176
|
-
// Host-detection precedence. Claude/Codex export very specific markers; check
|
|
177
|
-
// them BEFORE Cursor so a Claude Code or Codex session launched from inside a
|
|
178
|
-
// Cursor terminal (which still carries CURSOR_*) is not mis-wired to cursor-agent.
|
|
179
|
-
const HOST_PRECEDENCE = ["claude", "codex", "cursor"];
|
|
180
|
-
|
|
181
|
-
function isRunnable(bin) {
|
|
182
|
-
try {
|
|
183
|
-
return spawnSync(bin, ["--version"], { stdio: "ignore" }).status === 0;
|
|
184
|
-
} catch {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function findBin(agent) {
|
|
190
|
-
return agent.bins.find(isRunnable) || null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function detectHostAgent() {
|
|
194
|
-
for (const key of HOST_PRECEDENCE) {
|
|
195
|
-
const a = AGENTS.find((x) => x.key === key);
|
|
196
|
-
if (a && a.host()) return a;
|
|
197
|
-
}
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
125
|
+
// The agent table + (pure) detection live in server/agent-resolve.mjs, shared
|
|
126
|
+
// with the relay so it can re-check and self-heal at runtime; the CLI only adds
|
|
127
|
+
// the one thing the relay must not do — install the Cursor CLI.
|
|
200
128
|
|
|
201
129
|
// Install the Cursor CLI (the only agent we can fetch non-interactively).
|
|
202
130
|
function installCursorCli() {
|
|
@@ -215,50 +143,19 @@ function installCursorCli() {
|
|
|
215
143
|
return findBin(AGENTS[0]);
|
|
216
144
|
}
|
|
217
145
|
|
|
218
|
-
// Decide which agent CLI the relay should spawn.
|
|
146
|
+
// Decide which agent CLI the relay should spawn. Pure resolution is shared with
|
|
147
|
+
// the relay (resolveAgentCmd); the CLI layers on the one side-effecting step the
|
|
148
|
+
// relay must never do — installing the Cursor CLI when --llm is passed and
|
|
149
|
+
// nothing else is available. Returns:
|
|
219
150
|
// { cmd, agent, source } → wire this command (persistent LLM), or
|
|
220
151
|
// { cmd:null, agent?, reason } → couldn't wire; caller prints guidance.
|
|
221
|
-
// Precedence: explicit REFINE_AGENT_CMD → --agent <key> → host agent (same
|
|
222
|
-
// subscription) → any installed agent → (with --llm) install cursor-agent.
|
|
223
152
|
function resolveAgent({ wantLlm, forceKey }) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
target = AGENTS.find((a) => a.key === forceKey) || null;
|
|
231
|
-
if (!target) {
|
|
232
|
-
return { cmd: null, reason: `unknown --agent "${forceKey}" (use cursor | claude | codex)` };
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (!target) target = detectHostAgent();
|
|
236
|
-
|
|
237
|
-
if (target) {
|
|
238
|
-
let bin = findBin(target);
|
|
239
|
-
if (!bin && target.canInstall && wantLlm) bin = installCursorCli();
|
|
240
|
-
if (bin) return { cmd: target.cmd(bin), agent: target, source: forceKey ? "forced" : "host" };
|
|
241
|
-
return {
|
|
242
|
-
cmd: null,
|
|
243
|
-
agent: target,
|
|
244
|
-
reason:
|
|
245
|
-
`detected ${target.label} but its CLI isn't on PATH` +
|
|
246
|
-
(target.canInstall ? " — re-run with --llm to install it" : ` — install the ${target.label} CLI first`),
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// No host detected (plain terminal): use any installed agent, in list order.
|
|
251
|
-
for (const a of AGENTS) {
|
|
252
|
-
const bin = findBin(a);
|
|
253
|
-
if (bin) return { cmd: a.cmd(bin), agent: a, source: "scan" };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Nothing installed: only cursor-agent can be fetched non-interactively.
|
|
257
|
-
if (wantLlm) {
|
|
258
|
-
const bin = installCursorCli();
|
|
259
|
-
if (bin) return { cmd: AGENTS[0].cmd(bin), agent: AGENTS[0], source: "install" };
|
|
260
|
-
}
|
|
261
|
-
return { cmd: null, reason: "no agent CLI found" };
|
|
153
|
+
const r = resolveAgentCmd({ forceKey });
|
|
154
|
+
if (r.cmd || !wantLlm || !r.needsInstall) return r;
|
|
155
|
+
// wantLlm + the only fetchable CLI (cursor-agent) is missing → install it.
|
|
156
|
+
const bin = installCursorCli();
|
|
157
|
+
if (bin) return { cmd: AGENTS[0].cmd(bin), agent: AGENTS[0], source: "install" };
|
|
158
|
+
return r;
|
|
262
159
|
}
|
|
263
160
|
|
|
264
161
|
function log(msg) {
|
|
@@ -304,6 +201,47 @@ async function waitForHealth(port, totalMs = 6000) {
|
|
|
304
201
|
return false;
|
|
305
202
|
}
|
|
306
203
|
|
|
204
|
+
// GET /health → parsed JSON, or null if unreachable / unparsable.
|
|
205
|
+
function getHealth(port, timeoutMs = 800) {
|
|
206
|
+
return new Promise((resolveJson) => {
|
|
207
|
+
let done = false, body = "";
|
|
208
|
+
const finish = (v) => { if (!done) { done = true; resolveJson(v); } };
|
|
209
|
+
const req = httpGet({ host: "127.0.0.1", port: Number(port), path: "/health", timeout: timeoutMs }, (res) => {
|
|
210
|
+
if (res.statusCode < 200 || res.statusCode >= 500) { res.resume(); return finish(null); }
|
|
211
|
+
res.setEncoding("utf8");
|
|
212
|
+
res.on("data", (d) => (body += d));
|
|
213
|
+
res.on("end", () => { try { finish(JSON.parse(body)); } catch { finish(null); } });
|
|
214
|
+
});
|
|
215
|
+
req.on("timeout", () => { req.destroy(); finish(null); });
|
|
216
|
+
req.on("error", () => finish(null));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Best-effort POST (no body). Resolves true on a 2xx.
|
|
221
|
+
function postRelay(port, path, timeoutMs = 1500) {
|
|
222
|
+
return new Promise((resolveOk) => {
|
|
223
|
+
let done = false;
|
|
224
|
+
const finish = (v) => { if (!done) { done = true; resolveOk(v); } };
|
|
225
|
+
const req = httpRequest({ host: "127.0.0.1", port: Number(port), path, method: "POST", timeout: timeoutMs }, (res) => {
|
|
226
|
+
res.resume();
|
|
227
|
+
finish(res.statusCode >= 200 && res.statusCode < 300);
|
|
228
|
+
});
|
|
229
|
+
req.on("timeout", () => { req.destroy(); finish(false); });
|
|
230
|
+
req.on("error", () => finish(false));
|
|
231
|
+
req.end();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Wait until nothing answers on the port (a replaced relay has fully exited).
|
|
236
|
+
async function waitForPortFree(port, totalMs = 4000) {
|
|
237
|
+
const deadline = Date.now() + totalMs;
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
if (!(await httpOk(port))) return true;
|
|
240
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
307
245
|
// Kill a previously-detached relay for this project (best effort).
|
|
308
246
|
function stopDaemon({ silent = false } = {}) {
|
|
309
247
|
let rec = null;
|
|
@@ -370,6 +308,20 @@ async function cmdLive(args) {
|
|
|
370
308
|
const port = String(args.port || process.env.REFINE_RELAY_PORT || 7331);
|
|
371
309
|
const page = findPage(args.page);
|
|
372
310
|
|
|
311
|
+
// 0) reconcile any relay already on this port. A stale, OLDER-version relay is
|
|
312
|
+
// the classic "I updated but nothing changed" trap — npx serves a cached
|
|
313
|
+
// build, or an old daemon lingers. Replace it so `live` always converges to
|
|
314
|
+
// THIS version (and picks up fixes like agent self-heal).
|
|
315
|
+
const existing = await getHealth(port);
|
|
316
|
+
if (existing && existing.version && existing.version !== PKG_VERSION) {
|
|
317
|
+
log(`• replacing a stale relay on :${port} (v${existing.version} → v${PKG_VERSION})`);
|
|
318
|
+
await postRelay(port, "/shutdown");
|
|
319
|
+
stopDaemon({ silent: true });
|
|
320
|
+
if (!(await waitForPortFree(port))) {
|
|
321
|
+
log(`! the old relay on :${port} didn't exit — you may need to kill it manually.`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
373
325
|
// 1) inject the script tag
|
|
374
326
|
if (page) {
|
|
375
327
|
injectTag(page, port);
|
|
@@ -397,6 +349,8 @@ async function cmdLive(args) {
|
|
|
397
349
|
const wantLlm = Boolean(args.llm);
|
|
398
350
|
const forceKey = typeof args.agent === "string" ? args.agent : null;
|
|
399
351
|
const env = { ...process.env, REFINE_RELAY_PORT: port };
|
|
352
|
+
// Pass a forced --agent through so the relay's runtime self-heal honors it too.
|
|
353
|
+
if (forceKey) env.REFINE_AGENT = forceKey;
|
|
400
354
|
const resolved = resolveAgent({ wantLlm, forceKey });
|
|
401
355
|
if (resolved.cmd) {
|
|
402
356
|
env.REFINE_AGENT_CMD = resolved.cmd;
|
|
@@ -532,4 +486,5 @@ if (isCliEntry()) {
|
|
|
532
486
|
});
|
|
533
487
|
}
|
|
534
488
|
|
|
535
|
-
export {
|
|
489
|
+
export { resolveAgent };
|
|
490
|
+
export { AGENTS, HOST_PRECEDENCE, detectHostAgent, findBin } from "../server/agent-resolve.mjs";
|
package/demo.html
CHANGED
|
@@ -1399,6 +1399,22 @@
|
|
|
1399
1399
|
.tl-gate-text { margin: 4px 0 0; max-width: 250px; font-size: 13px; font-weight: 400; line-height: 21px;
|
|
1400
1400
|
color: #676767; text-wrap: pretty; }
|
|
1401
1401
|
.tl-gate-text .tl-code { color: #4b4b4b; }
|
|
1402
|
+
/* The relay's reason for not wiring a CLI (so a silent poller fallback is never
|
|
1403
|
+
a mystery), plus a Reconnect that forces an immediate agent re-check. */
|
|
1404
|
+
.tl-gate-reason { margin: 10px 0 0; max-width: 260px; font-size: 12px; line-height: 18px;
|
|
1405
|
+
color: #9a9a9a; text-wrap: pretty; }
|
|
1406
|
+
.tl-gate-recheck { margin-top: 12px; padding: 5px 12px; font: inherit; font-size: 12px; font-weight: 500;
|
|
1407
|
+
color: #4b4b4b; background: rgba(0, 0, 0, 0.04); border: 1px solid rgba(0, 0, 0, 0.08);
|
|
1408
|
+
border-radius: 8px; cursor: pointer;
|
|
1409
|
+
transition: background 150ms ease-out, border-color 150ms ease-out, opacity 150ms ease-out; }
|
|
1410
|
+
.tl-gate-recheck:hover { background: rgba(0, 0, 0, 0.07); border-color: rgba(0, 0, 0, 0.12); }
|
|
1411
|
+
.tl-gate-recheck:disabled { opacity: 0.55; cursor: default; }
|
|
1412
|
+
html[data-theme="dark"] .tl-gate-reason { color: #7e7e7e; }
|
|
1413
|
+
html[data-theme="dark"] .tl-gate-recheck { color: #d4d4d4; background: rgba(255, 255, 255, 0.06);
|
|
1414
|
+
border-color: rgba(255, 255, 255, 0.12); }
|
|
1415
|
+
html[data-theme="dark"] .tl-gate-recheck:hover { background: rgba(255, 255, 255, 0.1);
|
|
1416
|
+
border-color: rgba(255, 255, 255, 0.16); }
|
|
1417
|
+
@media (prefers-reduced-motion: reduce) { .tl-gate-recheck { transition: none; } }
|
|
1402
1418
|
@media (prefers-reduced-motion: reduce) {
|
|
1403
1419
|
.tl-gate { animation: none !important; } }
|
|
1404
1420
|
/* dot-matrix loader — ported from @dotmatrix/dotm-square-14
|
|
@@ -2473,6 +2489,14 @@
|
|
|
2473
2489
|
if(!r.ok) throw new Error("relay POST /poller/stop failed ("+r.status+")");
|
|
2474
2490
|
return r.json();
|
|
2475
2491
|
}
|
|
2492
|
+
// Ask the relay to re-resolve its agent CLI right now (bypassing its TTL).
|
|
2493
|
+
// Backs the "Reconnect" affordance after the user installs / logs into a CLI.
|
|
2494
|
+
async function relayRecheck(){
|
|
2495
|
+
if(TX_MOCK) return { ok:true, agentCmd:true };
|
|
2496
|
+
const r = await fetch(RELAY_URL+"/agent/recheck",{method:"POST"});
|
|
2497
|
+
if(!r.ok) throw new Error("relay POST /agent/recheck failed ("+r.status+")");
|
|
2498
|
+
return r.json();
|
|
2499
|
+
}
|
|
2476
2500
|
|
|
2477
2501
|
const REFINE_MODES=[
|
|
2478
2502
|
{key:"llm",label:"Agent",desc:"Suggested edits from the agent using the Transitions.dev skill."},
|
|
@@ -2662,18 +2686,31 @@
|
|
|
2662
2686
|
// Shared "Connect with your agent" copy — used by the full-panel gate and by
|
|
2663
2687
|
// the Refine area when no live agent is connected, so both read identically.
|
|
2664
2688
|
// `cliMissing` swaps the body to an install-first hint.
|
|
2665
|
-
function AgentConnectBody({cliMissing}){
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2689
|
+
function AgentConnectBody({cliMissing,reason,onReconnect}){
|
|
2690
|
+
const[rechecking,setRechecking]=useState(false);
|
|
2691
|
+
const recheck=async()=>{
|
|
2692
|
+
if(!onReconnect||rechecking) return;
|
|
2693
|
+
setRechecking(true);
|
|
2694
|
+
try{ await onReconnect(); } finally { setRechecking(false); }
|
|
2695
|
+
};
|
|
2696
|
+
return h(React.Fragment,null,
|
|
2697
|
+
cliMissing
|
|
2698
|
+
? h("p",{className:"tl-gate-text"},"Install an agent CLI, then run ",
|
|
2699
|
+
h("code",{className:"tl-code"},"npx transitions-refine live"),
|
|
2700
|
+
" (recommended) or ",h("code",{className:"tl-code"},"/refine live")," in your editor.")
|
|
2701
|
+
: h("p",{className:"tl-gate-text"},
|
|
2702
|
+
"Run ",h("code",{className:"tl-code"},"npx transitions-refine live"),
|
|
2703
|
+
" in your project to start the relay with a persistent agent — works for hours, no chat loop. Or run ",
|
|
2704
|
+
h("code",{className:"tl-code"},"/refine live"),
|
|
2705
|
+
" in your editor if the relay is already up without an agent wired."),
|
|
2706
|
+
// Surface WHY the relay couldn't wire a CLI (from /health agentReason) so a
|
|
2707
|
+
// silent fall-back to poller mode is never a mystery. The relay self-heals
|
|
2708
|
+
// on a TTL; Reconnect just forces an immediate re-check.
|
|
2709
|
+
reason&&h("p",{className:"tl-gate-reason"},reason),
|
|
2710
|
+
onReconnect&&h("button",{className:"tl-gate-recheck",disabled:rechecking,onClick:recheck},
|
|
2711
|
+
rechecking?"Reconnecting…":"Reconnect agent"));
|
|
2675
2712
|
}
|
|
2676
|
-
function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,onStart,lanes}){
|
|
2713
|
+
function RefinePanel({open,onClose,phase,label,refineType,onType,suggestions,summary,error,appliedIds,onApply,onApplyAll,mode,onMode,llmAvailable,cliInstalled,agentReason,onReconnect,onStart,lanes}){
|
|
2677
2714
|
// mount-on-open; keep mounted through the panel-reveal slide-out, then unmount
|
|
2678
2715
|
const[render,setRender]=useState(open);
|
|
2679
2716
|
const[panelOpen,setPanelOpen]=useState(false);
|
|
@@ -2783,7 +2820,7 @@
|
|
|
2783
2820
|
// /refine live makes the agent available and Start scanning works again.
|
|
2784
2821
|
return h("div",{className:"tl-refine-center"},
|
|
2785
2822
|
h("div",{className:"tl-refine-unavail-title"},"Connect with your agent"),
|
|
2786
|
-
h(AgentConnectBody,{cliMissing:cliInstalled===false}));
|
|
2823
|
+
h(AgentConnectBody,{cliMissing:cliInstalled===false,reason:agentReason,onReconnect}));
|
|
2787
2824
|
}
|
|
2788
2825
|
const idleDescR = agentMode ? (REFINE_TYPES.find(t=>t.key===rt)||REFINE_TYPES[0]).desc : (rt==="replace"
|
|
2789
2826
|
? "Deterministic mode can't pick a recipe to replace the transition — switch to Agent mode for replacements."
|
|
@@ -3774,6 +3811,7 @@
|
|
|
3774
3811
|
const[refineType,setRefineType]=useState("small"); // small | replace
|
|
3775
3812
|
const[llmAvailable,setLlmAvailable]=useState(null); // null=unknown, true/false from /health
|
|
3776
3813
|
const[cliInstalled,setCliInstalled]=useState(null); // null=unknown, true/false from /health
|
|
3814
|
+
const[agentReason,setAgentReason]=useState(null); // why no CLI is wired (/health agentReason)
|
|
3777
3815
|
// chatLoop = LLM served by the in-chat `/refine live` poll loop (pollerActive
|
|
3778
3816
|
// and no wired REFINE_AGENT_CMD). This is the mode that bills agent turns
|
|
3779
3817
|
// while idle, so the header shows a credit warning + Stop for it.
|
|
@@ -3797,6 +3835,7 @@
|
|
|
3797
3835
|
const refreshHealth=useCallback(async()=>{
|
|
3798
3836
|
try{const j=await relayHealth();setLlmAvailable(!!j.llmAvailable);
|
|
3799
3837
|
setCliInstalled(j.cliInstalled==null?null:!!j.cliInstalled);
|
|
3838
|
+
setAgentReason(j.agentReason||null);
|
|
3800
3839
|
const loop=!!j.pollerActive&&!j.agentCmd;
|
|
3801
3840
|
if(!stopPendingRef.current){chatLoopRef.current=loop;setChatLoop(loop);setLiveStopped(false);return j;}
|
|
3802
3841
|
if(!j.pollerActive)sawPollerGoneRef.current=true;
|
|
@@ -3814,6 +3853,12 @@
|
|
|
3814
3853
|
try{await relayStopLive();}catch{}
|
|
3815
3854
|
refreshHealth();
|
|
3816
3855
|
},[refreshHealth]);
|
|
3856
|
+
// Force the relay to re-resolve its agent CLI now, then refresh health so the
|
|
3857
|
+
// panel reflects a just-installed / just-logged-in agent without waiting.
|
|
3858
|
+
const reconnectAgent=useCallback(async()=>{
|
|
3859
|
+
try{await relayRecheck();}catch{}
|
|
3860
|
+
await refreshHealth();
|
|
3861
|
+
},[refreshHealth]);
|
|
3817
3862
|
// Quiet auto-stop after the one-time grouping scan: in chat-loop mode the
|
|
3818
3863
|
// loop has done its only needed job (the scan), so stop it to avoid idle
|
|
3819
3864
|
// credit burn. Drains like a normal stop but shows no chip; the Refine area
|
|
@@ -4529,13 +4574,13 @@
|
|
|
4529
4574
|
h(RefinePanel,{open:refineOpen,onClose:()=>setRefineOpen(false),phase:refinePhase,label:refineLabel,
|
|
4530
4575
|
refineType,onType:changeRefineType,suggestions:refineSuggestions,summary:refineSummary,error:refineError,
|
|
4531
4576
|
appliedIds,onApply:applySuggestion,onApplyAll:applyAllSuggestions,
|
|
4532
|
-
mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,onStart:startScan,
|
|
4577
|
+
mode:refineMode,onMode:changeRefineMode,llmAvailable,cliInstalled,agentReason,onReconnect:reconnectAgent,onStart:startScan,
|
|
4533
4578
|
lanes:active?.effectiveTimings||[]}))
|
|
4534
4579
|
: h("div",{className:"tl-panel-main"},
|
|
4535
4580
|
gate==="blocked" && h("div",{className:"tl-gate"},
|
|
4536
4581
|
h("div",{className:"tl-gate-col"},
|
|
4537
4582
|
h("div",{className:"tl-gate-title"},"Connect with your agent"),
|
|
4538
|
-
h(AgentConnectBody,{cliMissing:cliInstalled===false})))),
|
|
4583
|
+
h(AgentConnectBody,{cliMissing:cliInstalled===false,reason:agentReason,onReconnect:reconnectAgent})))),
|
|
4539
4584
|
toast&&createPortal(
|
|
4540
4585
|
h("div",{className:"tl-toast-wrap","data-tl-ui":"","aria-live":"polite"},
|
|
4541
4586
|
h("div",{className:cx("tl-toast",toast.closing&&"is-closing")},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transitions-refine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
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": {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Shared agent-CLI resolution, used by BOTH:
|
|
2
|
+
// • bin/cli.mjs — once, at `npx transitions-refine live` (may also install).
|
|
3
|
+
// • server/relay.mjs — repeatedly, at runtime, so the relay SELF-HEALS: if no
|
|
4
|
+
// agent was wired at boot (CLI not on PATH yet, not logged in, a startup
|
|
5
|
+
// race) the relay re-checks and auto-wires the moment one becomes available,
|
|
6
|
+
// instead of being stuck in /refine-live poller mode for its whole life.
|
|
7
|
+
//
|
|
8
|
+
// Everything here is PURE: detection by host env markers + a PATH scan, with no
|
|
9
|
+
// install and no logging, so the relay can call it on a short TTL with no side
|
|
10
|
+
// effects. Installation (Cursor CLI) stays in the CLI, which owns user prompts.
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
const HOME = process.env.HOME || homedir();
|
|
17
|
+
const envHasPrefix = (p, env) => Object.keys(env).some((k) => k.startsWith(p));
|
|
18
|
+
|
|
19
|
+
// The agent CLIs we know how to drive headlessly. `host(env)` detects the agent
|
|
20
|
+
// HOSTING this run (so Refine bills the subscription the user already has);
|
|
21
|
+
// `bins` are probed in order; `cmd(bin)` builds the headless invocation.
|
|
22
|
+
export const AGENTS = [
|
|
23
|
+
{
|
|
24
|
+
key: "cursor",
|
|
25
|
+
label: "Cursor",
|
|
26
|
+
host: (env) => Boolean(env.CURSOR_AGENT),
|
|
27
|
+
bins: [
|
|
28
|
+
"cursor-agent",
|
|
29
|
+
join(HOME, ".local/bin/cursor-agent"),
|
|
30
|
+
join(HOME, ".cursor/bin/cursor-agent"),
|
|
31
|
+
],
|
|
32
|
+
cmd: (bin) => `${bin} -p --force`,
|
|
33
|
+
canInstall: true,
|
|
34
|
+
auth: "run `cursor-agent` once to log in, or set CURSOR_API_KEY",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
key: "claude",
|
|
38
|
+
label: "Claude Code",
|
|
39
|
+
host: (env) => Boolean(env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT),
|
|
40
|
+
bins: [
|
|
41
|
+
"claude",
|
|
42
|
+
join(HOME, ".claude/local/claude"),
|
|
43
|
+
join(HOME, ".local/bin/claude"),
|
|
44
|
+
],
|
|
45
|
+
cmd: (bin) => `${bin} -p --dangerously-skip-permissions`,
|
|
46
|
+
canInstall: false,
|
|
47
|
+
auth: "run `claude` once to sign in",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
key: "codex",
|
|
51
|
+
label: "Codex",
|
|
52
|
+
host: (env) => Boolean(env.CODEX_SANDBOX) || envHasPrefix("CODEX_", env),
|
|
53
|
+
bins: ["codex", join(HOME, ".local/bin/codex")],
|
|
54
|
+
cmd: (bin) => `${bin} exec --sandbox workspace-write --skip-git-repo-check -`,
|
|
55
|
+
canInstall: false,
|
|
56
|
+
auth: "run `codex` once to sign in, or set CODEX_API_KEY",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// Host-detection precedence. Claude/Codex export very specific markers; check
|
|
61
|
+
// them BEFORE Cursor so a Claude Code or Codex session launched from inside a
|
|
62
|
+
// Cursor terminal (which still carries CURSOR_*) is not mis-wired to cursor-agent.
|
|
63
|
+
export const HOST_PRECEDENCE = ["claude", "codex", "cursor"];
|
|
64
|
+
|
|
65
|
+
// A bare `cursor-agent` goes interactive ("⚠ Workspace Trust Required", exit 1)
|
|
66
|
+
// and `codex exec` needs a trailing `-` to read the prompt from stdin. Normalise
|
|
67
|
+
// an externally-provided REFINE_AGENT_CMD so headless jobs don't silently fail.
|
|
68
|
+
export function augmentAgentCmd(cmd) {
|
|
69
|
+
if (!cmd) return cmd;
|
|
70
|
+
if (/(^|\s|\/)codex(\s|$)/.test(cmd) && /(^|\s)exec(\s|$)/.test(cmd)) {
|
|
71
|
+
return /(^|\s)-(\s|$)/.test(cmd) ? cmd : `${cmd} -`;
|
|
72
|
+
}
|
|
73
|
+
if (!/(^|\s|\/)cursor-agent(\s|$)/.test(cmd)) return cmd;
|
|
74
|
+
const has = (...flags) =>
|
|
75
|
+
flags.some((f) => new RegExp(`(^|\\s)${f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`).test(cmd));
|
|
76
|
+
const extra = [];
|
|
77
|
+
if (!has("-p", "--print")) extra.push("-p");
|
|
78
|
+
if (!has("--trust")) extra.push("--trust");
|
|
79
|
+
if (!has("-f", "--force", "--yolo")) extra.push("--force");
|
|
80
|
+
return extra.length ? `${cmd} ${extra.join(" ")}` : cmd;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isRunnable(bin) {
|
|
84
|
+
try {
|
|
85
|
+
return spawnSync(bin, ["--version"], { stdio: "ignore" }).status === 0;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function findBin(agent) {
|
|
92
|
+
return agent.bins.find(isRunnable) || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function detectHostAgent(env = process.env) {
|
|
96
|
+
for (const key of HOST_PRECEDENCE) {
|
|
97
|
+
const a = AGENTS.find((x) => x.key === key);
|
|
98
|
+
if (a && a.host(env)) return a;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pure resolution — no install, no logging. Returns one of:
|
|
104
|
+
// { cmd, agent, source } → wire this command, or
|
|
105
|
+
// { cmd:null, agent?, reason, needsInstall? } → couldn't wire (caller decides
|
|
106
|
+
// whether to install / surface).
|
|
107
|
+
// Precedence: explicit REFINE_AGENT_CMD → forced key → host agent → PATH scan.
|
|
108
|
+
export function resolveAgentCmd({ env = process.env, forceKey = null } = {}) {
|
|
109
|
+
if (env.REFINE_AGENT_CMD) {
|
|
110
|
+
return { cmd: augmentAgentCmd(env.REFINE_AGENT_CMD), source: "env" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let target = null;
|
|
114
|
+
if (forceKey) {
|
|
115
|
+
target = AGENTS.find((a) => a.key === forceKey) || null;
|
|
116
|
+
if (!target) {
|
|
117
|
+
return { cmd: null, reason: `unknown agent "${forceKey}" (use cursor | claude | codex)` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!target) target = detectHostAgent(env);
|
|
121
|
+
|
|
122
|
+
if (target) {
|
|
123
|
+
const bin = findBin(target);
|
|
124
|
+
if (bin) return { cmd: target.cmd(bin), agent: target, source: forceKey ? "forced" : "host" };
|
|
125
|
+
return {
|
|
126
|
+
cmd: null,
|
|
127
|
+
agent: target,
|
|
128
|
+
needsInstall: target.canInstall,
|
|
129
|
+
reason:
|
|
130
|
+
`detected ${target.label} but its CLI isn't on PATH` +
|
|
131
|
+
(target.canInstall ? " — re-run with --llm to install it" : ` — install the ${target.label} CLI first`),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const a of AGENTS) {
|
|
136
|
+
const bin = findBin(a);
|
|
137
|
+
if (bin) return { cmd: a.cmd(bin), agent: a, source: "scan" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { cmd: null, reason: "no agent CLI found", needsInstall: true };
|
|
141
|
+
}
|
package/server/relay.mjs
CHANGED
|
@@ -30,6 +30,7 @@ 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";
|
|
32
32
|
import { buildInjectModule } from "./inject.mjs";
|
|
33
|
+
import { resolveAgentCmd } from "./agent-resolve.mjs";
|
|
33
34
|
|
|
34
35
|
const PORT = Number(process.env.REFINE_RELAY_PORT) || 7331;
|
|
35
36
|
const AUTO = process.env.REFINE_AUTO !== "0";
|
|
@@ -40,30 +41,33 @@ try {
|
|
|
40
41
|
PKG_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || PKG_VERSION;
|
|
41
42
|
} catch {}
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
44
|
+
// Self-healing agent wiring. Instead of freezing the agent at boot — which left
|
|
45
|
+
// the relay stuck in /refine-live poller mode for its whole life if the CLI
|
|
46
|
+
// wasn't runnable at that one instant (not logged in yet, not on PATH, a startup
|
|
47
|
+
// race) — we re-resolve via the shared resolver on a short TTL. The moment an
|
|
48
|
+
// agent CLI becomes available, the relay wires it; if one disappears, it unwires.
|
|
49
|
+
// An explicit REFINE_AGENT_CMD still wins and is effectively pinned (the resolver
|
|
50
|
+
// short-circuits on it). REFINE_AGENT carries a forced --agent choice from the CLI.
|
|
51
|
+
const FORCE_AGENT = process.env.REFINE_AGENT || null;
|
|
52
|
+
const AGENT_RECHECK_MS = Number(process.env.REFINE_AGENT_RECHECK_MS) || 5000;
|
|
53
|
+
let _agent = { at: 0, cmd: null, source: null, reason: null, label: null };
|
|
54
|
+
function agentInfo(force = false) {
|
|
55
|
+
if (force || _agent.at === 0 || now() - _agent.at >= AGENT_RECHECK_MS) {
|
|
56
|
+
const r = resolveAgentCmd({ forceKey: FORCE_AGENT });
|
|
57
|
+
const prev = _agent.cmd;
|
|
58
|
+
_agent = {
|
|
59
|
+
at: now(),
|
|
60
|
+
cmd: r.cmd || null,
|
|
61
|
+
source: r.source || null,
|
|
62
|
+
reason: r.reason || null,
|
|
63
|
+
label: (r.agent && r.agent.label) || null,
|
|
64
|
+
};
|
|
65
|
+
if (r.cmd && r.cmd !== prev) console.log(`✓ agent wired${r.source ? ` (${r.source})` : ""}: ${r.cmd}`);
|
|
66
|
+
else if (!r.cmd && prev) console.log(`• agent unwired — ${r.reason || "no agent CLI"}`);
|
|
57
67
|
}
|
|
58
|
-
|
|
59
|
-
const has = (...flags) => flags.some((f) => new RegExp(`(^|\\s)${f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`).test(cmd));
|
|
60
|
-
const extra = [];
|
|
61
|
-
if (!has("-p", "--print")) extra.push("-p");
|
|
62
|
-
if (!has("--trust")) extra.push("--trust");
|
|
63
|
-
if (!has("-f", "--force", "--yolo")) extra.push("--force");
|
|
64
|
-
return extra.length ? `${cmd} ${extra.join(" ")}` : cmd;
|
|
68
|
+
return _agent;
|
|
65
69
|
}
|
|
66
|
-
const
|
|
70
|
+
const agentCmd = () => agentInfo().cmd;
|
|
67
71
|
// Pin a fast model for scan jobs. Grouping is a structured task that doesn't
|
|
68
72
|
// need a heavy reasoning model, and the user's *default* model may be a slow one
|
|
69
73
|
// (Opus / GPT-5.5) — forcing a fast model here keeps the initial scan snappy.
|
|
@@ -134,13 +138,6 @@ const PENDING_TIMEOUT_MS = Number(process.env.REFINE_PENDING_TIMEOUT_MS) || 1200
|
|
|
134
138
|
// to stop (it returns {stop:true} from /jobs/next). 0 disables. Only the chat
|
|
135
139
|
// loop is affected — a wired REFINE_AGENT_CMD never polls /jobs/next.
|
|
136
140
|
const POLLER_IDLE_STOP_MS = Number(process.env.REFINE_POLLER_IDLE_STOP_MS) || 600000;
|
|
137
|
-
// While the Stop latch is held, a genuinely new `/refine live` session can resume
|
|
138
|
-
// itself implicitly: only after polling has been *quiet* this long (the old loop
|
|
139
|
-
// actually died) do we treat the next poll as a fresh session and auto-resume.
|
|
140
|
-
// Must exceed a looping agent's poll gap, so an agent that ignores Stop and keeps
|
|
141
|
-
// polling can never clear the latch — it just keeps getting {stop:true}. (An
|
|
142
|
-
// updated agent also resumes explicitly via POST /poller/start, no wait needed.)
|
|
143
|
-
const STOP_QUIET_MS = Number(process.env.REFINE_STOP_QUIET_MS) || 15000;
|
|
144
141
|
|
|
145
142
|
/** @type {Map<string, Job>} */
|
|
146
143
|
const jobs = new Map();
|
|
@@ -156,11 +153,8 @@ let lastPollAt = 0;
|
|
|
156
153
|
// re-polls — can never silently revive the session. This is what makes the
|
|
157
154
|
// panel's Stop button stick instead of flipping "Live" back on seconds later.
|
|
158
155
|
let pollerStopped = false;
|
|
159
|
-
// When did a poll last arrive *while latched*? Used to detect that the old loop
|
|
160
|
-
// went quiet so a genuinely new session (or re-run) can auto-resume.
|
|
161
|
-
let lastStoppedPollAt = 0;
|
|
162
156
|
const pollerActive = () => !pollerStopped && now() - lastPollAt < POLLER_TTL_MS;
|
|
163
|
-
const llmAvailable = () => Boolean(
|
|
157
|
+
const llmAvailable = () => Boolean(agentCmd()) || pollerActive();
|
|
164
158
|
|
|
165
159
|
// Stop signal for the in-chat `/refine live` loop. Set by POST /poller/stop
|
|
166
160
|
// (the panel's "Stop" button) or by the idle auto-stop; consumed by the next
|
|
@@ -181,7 +175,7 @@ const AGENT_BIN_CANDIDATES = [
|
|
|
181
175
|
"/opt/homebrew/bin/cursor-agent",
|
|
182
176
|
];
|
|
183
177
|
function cursorCliInstalled() {
|
|
184
|
-
if (
|
|
178
|
+
if (agentCmd()) return true;
|
|
185
179
|
for (const p of AGENT_BIN_CANDIDATES) {
|
|
186
180
|
try { if (existsSync(p)) return true; } catch {}
|
|
187
181
|
}
|
|
@@ -547,6 +541,9 @@ function refineDeterministic(job) {
|
|
|
547
541
|
async function answerJob(job) {
|
|
548
542
|
job.status = "working";
|
|
549
543
|
job.updatedAt = now();
|
|
544
|
+
// Snapshot the current wiring once for this job (the resolver is self-healing
|
|
545
|
+
// on a TTL; capturing avoids a mid-job change between the guard and the spawn).
|
|
546
|
+
const AGENT_CMD = agentCmd();
|
|
550
547
|
const isApply = job.request?.kind === "apply";
|
|
551
548
|
const isScan = job.request?.kind === "scan";
|
|
552
549
|
const label = job.request?.label || job.request?.selector || "transition";
|
|
@@ -659,6 +656,7 @@ const server = createServer(async (req, res) => {
|
|
|
659
656
|
if (method === "OPTIONS") return send(res, 204);
|
|
660
657
|
|
|
661
658
|
if (method === "GET" && path === "/health") {
|
|
659
|
+
const ai = agentInfo();
|
|
662
660
|
return send(res, 200, {
|
|
663
661
|
ok: true,
|
|
664
662
|
version: PKG_VERSION,
|
|
@@ -666,7 +664,12 @@ const server = createServer(async (req, res) => {
|
|
|
666
664
|
llmAvailable: llmAvailable(),
|
|
667
665
|
pollerActive: pollerActive(),
|
|
668
666
|
pollerStopped,
|
|
669
|
-
agentCmd: Boolean(
|
|
667
|
+
agentCmd: Boolean(ai.cmd),
|
|
668
|
+
// Why LLM mode isn't wired by a spawned CLI (panel surfaces this + offers a
|
|
669
|
+
// Reconnect that POSTs /agent/recheck). null when an agent IS wired.
|
|
670
|
+
agentSource: ai.source,
|
|
671
|
+
agentLabel: ai.label,
|
|
672
|
+
agentReason: ai.cmd ? null : ai.reason,
|
|
670
673
|
cliInstalled: cursorCliInstalled(),
|
|
671
674
|
jobs: jobs.size,
|
|
672
675
|
});
|
|
@@ -704,7 +707,7 @@ const server = createServer(async (req, res) => {
|
|
|
704
707
|
// External-poller-only mode: everything waits on GET /jobs/next.
|
|
705
708
|
} else if (mode === "deterministic") {
|
|
706
709
|
setImmediate(() => answerJob(job)); // in-process, off the response path
|
|
707
|
-
} else if (
|
|
710
|
+
} else if (agentCmd()) {
|
|
708
711
|
setImmediate(() => answerJob(job)); // spawn the configured CLI once
|
|
709
712
|
} else if (pollerActive()) {
|
|
710
713
|
// A `/refine live` agent is polling — leave it pending for them to claim.
|
|
@@ -727,21 +730,15 @@ const server = createServer(async (req, res) => {
|
|
|
727
730
|
|
|
728
731
|
// GET /jobs/next — long-poll claimed by a `/refine live` agent (LLM jobs).
|
|
729
732
|
if (method === "GET" && path === "/jobs/next") {
|
|
730
|
-
// Stopped latch: a Stop was issued and not yet resumed.
|
|
731
|
-
//
|
|
732
|
-
// /poller/start
|
|
733
|
-
//
|
|
734
|
-
//
|
|
733
|
+
// Stopped latch: a Stop was issued and not yet resumed. STICKY — every poll
|
|
734
|
+
// gets {stop:true} and the poller reports inactive until an explicit
|
|
735
|
+
// POST /poller/start (a fresh `/refine live` / loop announcing itself) or a
|
|
736
|
+
// relay restart. We deliberately do NOT auto-resume on a quiet gap: a stubborn
|
|
737
|
+
// agent's straggler poll arriving seconds later would be misread as a new
|
|
738
|
+
// session and flip the panel back to "Live" — which is exactly the bug where
|
|
739
|
+
// Stop "didn't stick". A pending job still wins so we never drop real work.
|
|
735
740
|
if (pollerStopped && !nextPendingLlm()) {
|
|
736
|
-
|
|
737
|
-
// Quiet gap → the previous loop is gone. This poll is a fresh session.
|
|
738
|
-
pollerStopped = false;
|
|
739
|
-
lastStoppedPollAt = 0;
|
|
740
|
-
// fall through to normal handling below (counts as live again)
|
|
741
|
-
} else {
|
|
742
|
-
lastStoppedPollAt = now(); // a poller is still hammering us → hold the latch
|
|
743
|
-
return send(res, 200, { stop: true });
|
|
744
|
-
}
|
|
741
|
+
return send(res, 200, { stop: true });
|
|
745
742
|
}
|
|
746
743
|
lastPollAt = now();
|
|
747
744
|
if (!lastJobAt) lastJobAt = now(); // seed idle window on the loop's first poll
|
|
@@ -749,8 +746,7 @@ const server = createServer(async (req, res) => {
|
|
|
749
746
|
// loop to exit. A pending job always wins so we never drop real work.
|
|
750
747
|
if ((stopRequested || (POLLER_IDLE_STOP_MS && now() - lastJobAt >= POLLER_IDLE_STOP_MS)) && !nextPendingLlm()) {
|
|
751
748
|
stopRequested = false;
|
|
752
|
-
pollerStopped = true; // latch until /poller/start
|
|
753
|
-
lastStoppedPollAt = 0;
|
|
749
|
+
pollerStopped = true; // latch until an explicit /poller/start
|
|
754
750
|
lastJobAt = 0;
|
|
755
751
|
lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
|
|
756
752
|
return send(res, 200, { stop: true });
|
|
@@ -760,8 +756,7 @@ const server = createServer(async (req, res) => {
|
|
|
760
756
|
if (res.writableEnded) return;
|
|
761
757
|
if (stopRequested && !nextPendingLlm()) {
|
|
762
758
|
stopRequested = false;
|
|
763
|
-
pollerStopped = true; // latch until /poller/start
|
|
764
|
-
lastStoppedPollAt = 0;
|
|
759
|
+
pollerStopped = true; // latch until an explicit /poller/start
|
|
765
760
|
lastJobAt = 0;
|
|
766
761
|
lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
|
|
767
762
|
return send(res, 200, { stop: true });
|
|
@@ -784,19 +779,34 @@ const server = createServer(async (req, res) => {
|
|
|
784
779
|
if (method === "POST" && path === "/poller/stop") {
|
|
785
780
|
const wasActive = now() - lastPollAt < POLLER_TTL_MS;
|
|
786
781
|
stopRequested = true;
|
|
787
|
-
pollerStopped = true; // latch: stays stopped until /poller/start
|
|
788
|
-
lastStoppedPollAt = 0;
|
|
782
|
+
pollerStopped = true; // latch: stays stopped until an explicit /poller/start
|
|
789
783
|
lastPollAt = 0; // report inactive immediately; a straggler poll won't revive it
|
|
790
784
|
return send(res, 200, { ok: true, stopping: wasActive });
|
|
791
785
|
}
|
|
792
786
|
|
|
787
|
+
// POST /shutdown — let the CLI replace a stale relay cleanly. `live` calls this
|
|
788
|
+
// when it finds an older-version relay on the port (the npx-cache / lingering-
|
|
789
|
+
// daemon trap) so a re-run always converges to the current build.
|
|
790
|
+
if (method === "POST" && path === "/shutdown") {
|
|
791
|
+
send(res, 200, { ok: true, version: PKG_VERSION });
|
|
792
|
+
setTimeout(() => process.exit(0), 50);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// POST /agent/recheck — force an immediate agent re-resolve, bypassing the TTL.
|
|
797
|
+
// Backs the panel's "Reconnect" affordance: after the user installs / logs into
|
|
798
|
+
// a CLI, this wires it right away instead of waiting out the recheck interval.
|
|
799
|
+
if (method === "POST" && path === "/agent/recheck") {
|
|
800
|
+
const ai = agentInfo(true);
|
|
801
|
+
return send(res, 200, { ok: true, agentCmd: Boolean(ai.cmd), agentLabel: ai.label, agentReason: ai.cmd ? null : ai.reason });
|
|
802
|
+
}
|
|
803
|
+
|
|
793
804
|
// POST /poller/start — a fresh `/refine live` agent (or loop) announces itself
|
|
794
805
|
// and clears the Stop latch so its polls count as live again. Without this an
|
|
795
|
-
// explicit re-run
|
|
796
|
-
// startup, before the first GET /jobs/next.
|
|
806
|
+
// explicit re-run couldn't resume at all (the sticky latch tells every poll to
|
|
807
|
+
// stop). Call it once at loop startup, before the first GET /jobs/next.
|
|
797
808
|
if (method === "POST" && path === "/poller/start") {
|
|
798
809
|
pollerStopped = false;
|
|
799
|
-
lastStoppedPollAt = 0;
|
|
800
810
|
stopRequested = false;
|
|
801
811
|
lastJobAt = now();
|
|
802
812
|
lastPollAt = now();
|
|
@@ -861,11 +871,13 @@ const server = createServer(async (req, res) => {
|
|
|
861
871
|
server.listen(PORT, () => {
|
|
862
872
|
console.log(`refine relay listening on http://localhost:${PORT}`);
|
|
863
873
|
console.log(` timeline injectable at http://localhost:${PORT}/inject.js`);
|
|
874
|
+
const ai = agentInfo();
|
|
864
875
|
if (!AUTO) {
|
|
865
876
|
console.log(" auto-answer OFF (REFINE_AUTO=0) — all jobs wait for a poller on GET /jobs/next");
|
|
866
|
-
} else if (
|
|
867
|
-
console.log(` LLM jobs answered by spawning: ${
|
|
877
|
+
} else if (ai.cmd) {
|
|
878
|
+
console.log(` LLM jobs answered by spawning: ${ai.cmd}`);
|
|
868
879
|
} else {
|
|
880
|
+
console.log(` no agent CLI wired yet (${ai.reason || "none found"}) — re-checking every ${Math.round(AGENT_RECHECK_MS / 1000)}s.`);
|
|
869
881
|
console.log(" LLM jobs wait for a live agent — run `/refine live` in Cursor/Codex.");
|
|
870
882
|
console.log(` live agent stays 'available' for ${Math.round(POLLER_TTL_MS / 1000)}s after its last poll.`);
|
|
871
883
|
console.log(" Deterministic jobs answered in-process (nearest motion token).");
|