transitions-refine 0.3.19 → 0.3.20
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 +60 -28
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.20",
|
|
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.
|
|
@@ -160,7 +164,7 @@ let pollerStopped = false;
|
|
|
160
164
|
// went quiet so a genuinely new session (or re-run) can auto-resume.
|
|
161
165
|
let lastStoppedPollAt = 0;
|
|
162
166
|
const pollerActive = () => !pollerStopped && now() - lastPollAt < POLLER_TTL_MS;
|
|
163
|
-
const llmAvailable = () => Boolean(
|
|
167
|
+
const llmAvailable = () => Boolean(agentCmd()) || pollerActive();
|
|
164
168
|
|
|
165
169
|
// Stop signal for the in-chat `/refine live` loop. Set by POST /poller/stop
|
|
166
170
|
// (the panel's "Stop" button) or by the idle auto-stop; consumed by the next
|
|
@@ -181,7 +185,7 @@ const AGENT_BIN_CANDIDATES = [
|
|
|
181
185
|
"/opt/homebrew/bin/cursor-agent",
|
|
182
186
|
];
|
|
183
187
|
function cursorCliInstalled() {
|
|
184
|
-
if (
|
|
188
|
+
if (agentCmd()) return true;
|
|
185
189
|
for (const p of AGENT_BIN_CANDIDATES) {
|
|
186
190
|
try { if (existsSync(p)) return true; } catch {}
|
|
187
191
|
}
|
|
@@ -547,6 +551,9 @@ function refineDeterministic(job) {
|
|
|
547
551
|
async function answerJob(job) {
|
|
548
552
|
job.status = "working";
|
|
549
553
|
job.updatedAt = now();
|
|
554
|
+
// Snapshot the current wiring once for this job (the resolver is self-healing
|
|
555
|
+
// on a TTL; capturing avoids a mid-job change between the guard and the spawn).
|
|
556
|
+
const AGENT_CMD = agentCmd();
|
|
550
557
|
const isApply = job.request?.kind === "apply";
|
|
551
558
|
const isScan = job.request?.kind === "scan";
|
|
552
559
|
const label = job.request?.label || job.request?.selector || "transition";
|
|
@@ -659,6 +666,7 @@ const server = createServer(async (req, res) => {
|
|
|
659
666
|
if (method === "OPTIONS") return send(res, 204);
|
|
660
667
|
|
|
661
668
|
if (method === "GET" && path === "/health") {
|
|
669
|
+
const ai = agentInfo();
|
|
662
670
|
return send(res, 200, {
|
|
663
671
|
ok: true,
|
|
664
672
|
version: PKG_VERSION,
|
|
@@ -666,7 +674,12 @@ const server = createServer(async (req, res) => {
|
|
|
666
674
|
llmAvailable: llmAvailable(),
|
|
667
675
|
pollerActive: pollerActive(),
|
|
668
676
|
pollerStopped,
|
|
669
|
-
agentCmd: Boolean(
|
|
677
|
+
agentCmd: Boolean(ai.cmd),
|
|
678
|
+
// Why LLM mode isn't wired by a spawned CLI (panel surfaces this + offers a
|
|
679
|
+
// Reconnect that POSTs /agent/recheck). null when an agent IS wired.
|
|
680
|
+
agentSource: ai.source,
|
|
681
|
+
agentLabel: ai.label,
|
|
682
|
+
agentReason: ai.cmd ? null : ai.reason,
|
|
670
683
|
cliInstalled: cursorCliInstalled(),
|
|
671
684
|
jobs: jobs.size,
|
|
672
685
|
});
|
|
@@ -704,7 +717,7 @@ const server = createServer(async (req, res) => {
|
|
|
704
717
|
// External-poller-only mode: everything waits on GET /jobs/next.
|
|
705
718
|
} else if (mode === "deterministic") {
|
|
706
719
|
setImmediate(() => answerJob(job)); // in-process, off the response path
|
|
707
|
-
} else if (
|
|
720
|
+
} else if (agentCmd()) {
|
|
708
721
|
setImmediate(() => answerJob(job)); // spawn the configured CLI once
|
|
709
722
|
} else if (pollerActive()) {
|
|
710
723
|
// A `/refine live` agent is polling — leave it pending for them to claim.
|
|
@@ -790,6 +803,23 @@ const server = createServer(async (req, res) => {
|
|
|
790
803
|
return send(res, 200, { ok: true, stopping: wasActive });
|
|
791
804
|
}
|
|
792
805
|
|
|
806
|
+
// POST /shutdown — let the CLI replace a stale relay cleanly. `live` calls this
|
|
807
|
+
// when it finds an older-version relay on the port (the npx-cache / lingering-
|
|
808
|
+
// daemon trap) so a re-run always converges to the current build.
|
|
809
|
+
if (method === "POST" && path === "/shutdown") {
|
|
810
|
+
send(res, 200, { ok: true, version: PKG_VERSION });
|
|
811
|
+
setTimeout(() => process.exit(0), 50);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// POST /agent/recheck — force an immediate agent re-resolve, bypassing the TTL.
|
|
816
|
+
// Backs the panel's "Reconnect" affordance: after the user installs / logs into
|
|
817
|
+
// a CLI, this wires it right away instead of waiting out the recheck interval.
|
|
818
|
+
if (method === "POST" && path === "/agent/recheck") {
|
|
819
|
+
const ai = agentInfo(true);
|
|
820
|
+
return send(res, 200, { ok: true, agentCmd: Boolean(ai.cmd), agentLabel: ai.label, agentReason: ai.cmd ? null : ai.reason });
|
|
821
|
+
}
|
|
822
|
+
|
|
793
823
|
// POST /poller/start — a fresh `/refine live` agent (or loop) announces itself
|
|
794
824
|
// and clears the Stop latch so its polls count as live again. Without this an
|
|
795
825
|
// explicit re-run would have to wait out the quiet window. Call it once at loop
|
|
@@ -861,11 +891,13 @@ const server = createServer(async (req, res) => {
|
|
|
861
891
|
server.listen(PORT, () => {
|
|
862
892
|
console.log(`refine relay listening on http://localhost:${PORT}`);
|
|
863
893
|
console.log(` timeline injectable at http://localhost:${PORT}/inject.js`);
|
|
894
|
+
const ai = agentInfo();
|
|
864
895
|
if (!AUTO) {
|
|
865
896
|
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: ${
|
|
897
|
+
} else if (ai.cmd) {
|
|
898
|
+
console.log(` LLM jobs answered by spawning: ${ai.cmd}`);
|
|
868
899
|
} else {
|
|
900
|
+
console.log(` no agent CLI wired yet (${ai.reason || "none found"}) — re-checking every ${Math.round(AGENT_RECHECK_MS / 1000)}s.`);
|
|
869
901
|
console.log(" LLM jobs wait for a live agent — run `/refine live` in Cursor/Codex.");
|
|
870
902
|
console.log(` live agent stays 'available' for ${Math.round(POLLER_TTL_MS / 1000)}s after its last poll.`);
|
|
871
903
|
console.log(" Deterministic jobs answered in-process (nearest motion token).");
|