transitions-refine 0.3.18 → 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 +210 -126
- 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
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
// 4. starts the local refine relay (serves the panel at /inject.js).
|
|
20
20
|
|
|
21
21
|
import { spawn, spawnSync } from "node:child_process";
|
|
22
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, realpathSync } from "node:fs";
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, realpathSync, openSync, rmSync } from "node:fs";
|
|
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, request as httpRequest } from "node:http";
|
|
27
|
+
import { AGENTS, findBin, resolveAgentCmd } from "../server/agent-resolve.mjs";
|
|
26
28
|
|
|
27
29
|
const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
28
30
|
const CWD = process.cwd();
|
|
@@ -120,82 +122,9 @@ function dropSkill(name) {
|
|
|
120
122
|
// prompt, stdout = JSON). To bill against the account the user ALREADY pays for,
|
|
121
123
|
// we detect which agent is hosting this run and wire ITS CLI: a Claude Code user
|
|
122
124
|
// gets `claude`, a Codex user gets `codex`, a Cursor user gets `cursor-agent`.
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const AGENTS = [
|
|
128
|
-
{
|
|
129
|
-
key: "cursor",
|
|
130
|
-
label: "Cursor",
|
|
131
|
-
// Cursor's agent terminal exports CURSOR_AGENT.
|
|
132
|
-
host: () => Boolean(process.env.CURSOR_AGENT),
|
|
133
|
-
bins: [
|
|
134
|
-
"cursor-agent",
|
|
135
|
-
join(HOME, ".local/bin/cursor-agent"),
|
|
136
|
-
join(HOME, ".cursor/bin/cursor-agent"),
|
|
137
|
-
],
|
|
138
|
-
// -p = headless/stdin, --force = auto-allow tool calls. The relay also
|
|
139
|
-
// auto-appends -p/--trust/--force for cursor-agent; we wire them up front so
|
|
140
|
-
// the printed command is the real one.
|
|
141
|
-
cmd: (bin) => `${bin} -p --force`,
|
|
142
|
-
canInstall: true,
|
|
143
|
-
auth: "run `cursor-agent` once to log in, or set CURSOR_API_KEY",
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
key: "claude",
|
|
147
|
-
label: "Claude Code",
|
|
148
|
-
// Claude Code exports CLAUDECODE=1 (+ CLAUDE_CODE_*) in its tools/terminals.
|
|
149
|
-
host: () => Boolean(process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT),
|
|
150
|
-
bins: [
|
|
151
|
-
"claude",
|
|
152
|
-
join(HOME, ".claude/local/claude"),
|
|
153
|
-
join(HOME, ".local/bin/claude"),
|
|
154
|
-
],
|
|
155
|
-
// -p = headless print (prompt on stdin); skip-permissions so apply jobs can
|
|
156
|
-
// edit files without an interactive approval prompt.
|
|
157
|
-
cmd: (bin) => `${bin} -p --dangerously-skip-permissions`,
|
|
158
|
-
canInstall: false,
|
|
159
|
-
auth: "run `claude` once to sign in",
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
key: "codex",
|
|
163
|
-
label: "Codex",
|
|
164
|
-
// Codex exec exports CODEX_SANDBOX (+ CODEX_* friends) in its sandbox.
|
|
165
|
-
host: () => Boolean(process.env.CODEX_SANDBOX) || envHasPrefix("CODEX_"),
|
|
166
|
-
bins: ["codex", join(HOME, ".local/bin/codex")],
|
|
167
|
-
// `codex exec -` reads the prompt on stdin; workspace-write so apply jobs can
|
|
168
|
-
// edit files; skip-git-repo-check so a non-git project root doesn't error out.
|
|
169
|
-
cmd: (bin) => `${bin} exec --sandbox workspace-write --skip-git-repo-check -`,
|
|
170
|
-
canInstall: false,
|
|
171
|
-
auth: "run `codex` once to sign in, or set CODEX_API_KEY",
|
|
172
|
-
},
|
|
173
|
-
];
|
|
174
|
-
|
|
175
|
-
// Host-detection precedence. Claude/Codex export very specific markers; check
|
|
176
|
-
// them BEFORE Cursor so a Claude Code or Codex session launched from inside a
|
|
177
|
-
// Cursor terminal (which still carries CURSOR_*) is not mis-wired to cursor-agent.
|
|
178
|
-
const HOST_PRECEDENCE = ["claude", "codex", "cursor"];
|
|
179
|
-
|
|
180
|
-
function isRunnable(bin) {
|
|
181
|
-
try {
|
|
182
|
-
return spawnSync(bin, ["--version"], { stdio: "ignore" }).status === 0;
|
|
183
|
-
} catch {
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function findBin(agent) {
|
|
189
|
-
return agent.bins.find(isRunnable) || null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function detectHostAgent() {
|
|
193
|
-
for (const key of HOST_PRECEDENCE) {
|
|
194
|
-
const a = AGENTS.find((x) => x.key === key);
|
|
195
|
-
if (a && a.host()) return a;
|
|
196
|
-
}
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
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.
|
|
199
128
|
|
|
200
129
|
// Install the Cursor CLI (the only agent we can fetch non-interactively).
|
|
201
130
|
function installCursorCli() {
|
|
@@ -214,60 +143,185 @@ function installCursorCli() {
|
|
|
214
143
|
return findBin(AGENTS[0]);
|
|
215
144
|
}
|
|
216
145
|
|
|
217
|
-
// 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:
|
|
218
150
|
// { cmd, agent, source } → wire this command (persistent LLM), or
|
|
219
151
|
// { cmd:null, agent?, reason } → couldn't wire; caller prints guidance.
|
|
220
|
-
// Precedence: explicit REFINE_AGENT_CMD → --agent <key> → host agent (same
|
|
221
|
-
// subscription) → any installed agent → (with --llm) install cursor-agent.
|
|
222
152
|
function resolveAgent({ wantLlm, forceKey }) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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;
|
|
159
|
+
}
|
|
226
160
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!target) target = detectHostAgent();
|
|
235
|
-
|
|
236
|
-
if (target) {
|
|
237
|
-
let bin = findBin(target);
|
|
238
|
-
if (!bin && target.canInstall && wantLlm) bin = installCursorCli();
|
|
239
|
-
if (bin) return { cmd: target.cmd(bin), agent: target, source: forceKey ? "forced" : "host" };
|
|
240
|
-
return {
|
|
241
|
-
cmd: null,
|
|
242
|
-
agent: target,
|
|
243
|
-
reason:
|
|
244
|
-
`detected ${target.label} but its CLI isn't on PATH` +
|
|
245
|
-
(target.canInstall ? " — re-run with --llm to install it" : ` — install the ${target.label} CLI first`),
|
|
246
|
-
};
|
|
247
|
-
}
|
|
161
|
+
function log(msg) {
|
|
162
|
+
process.stdout.write(`${msg}\n`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function rel(p) {
|
|
166
|
+
return p && p.startsWith(CWD + "/") ? p.slice(CWD.length + 1) : p;
|
|
167
|
+
}
|
|
248
168
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
169
|
+
// ── background daemon (for one-shot agent shells) ────────────────────────────
|
|
170
|
+
// Claude Code / Codex / Cursor run a `live` command in a NON-interactive shell
|
|
171
|
+
// that expects the command to EXIT. A foreground relay never exits, so the agent
|
|
172
|
+
// shell buffers (no output) and eventually kills the process group — which runs
|
|
173
|
+
// cleanup and strips the injected tag, so "nothing works". In those shells we
|
|
174
|
+
// instead fork the relay as a detached daemon (its own session via detached:true,
|
|
175
|
+
// so a process-group kill on the agent shell can't reach it), record a PID file,
|
|
176
|
+
// print status, and exit 0. `stop` reads the PID file to tear it down.
|
|
177
|
+
function daemonFile() {
|
|
178
|
+
return join(CWD, ".refine-relay.json");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// One GET /health probe. Resolves true if the relay answers at all.
|
|
182
|
+
function httpOk(port, path = "/health", timeoutMs = 800) {
|
|
183
|
+
return new Promise((resolveOk) => {
|
|
184
|
+
let done = false;
|
|
185
|
+
const finish = (v) => { if (!done) { done = true; resolveOk(v); } };
|
|
186
|
+
const req = httpGet({ host: "127.0.0.1", port: Number(port), path, timeout: timeoutMs }, (res) => {
|
|
187
|
+
res.resume(); // drain so the socket can close
|
|
188
|
+
finish(res.statusCode >= 200 && res.statusCode < 500);
|
|
189
|
+
});
|
|
190
|
+
req.on("timeout", () => { req.destroy(); finish(false); });
|
|
191
|
+
req.on("error", () => finish(false));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function waitForHealth(port, totalMs = 6000) {
|
|
196
|
+
const deadline = Date.now() + totalMs;
|
|
197
|
+
while (Date.now() < deadline) {
|
|
198
|
+
if (await httpOk(port)) return true;
|
|
199
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
253
200
|
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
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
|
+
}
|
|
254
234
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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));
|
|
259
241
|
}
|
|
260
|
-
return
|
|
242
|
+
return false;
|
|
261
243
|
}
|
|
262
244
|
|
|
263
|
-
|
|
264
|
-
|
|
245
|
+
// Kill a previously-detached relay for this project (best effort).
|
|
246
|
+
function stopDaemon({ silent = false } = {}) {
|
|
247
|
+
let rec = null;
|
|
248
|
+
try { rec = JSON.parse(readFileSync(daemonFile(), "utf8")); } catch {}
|
|
249
|
+
if (!rec || !rec.pid) return false;
|
|
250
|
+
let killed = false;
|
|
251
|
+
try { process.kill(rec.pid, "SIGTERM"); killed = true; } catch {}
|
|
252
|
+
try { rmSync(daemonFile()); } catch {}
|
|
253
|
+
if (killed && !silent) log(`✓ stopped background relay (pid ${rec.pid})`);
|
|
254
|
+
return killed;
|
|
265
255
|
}
|
|
266
256
|
|
|
267
|
-
|
|
257
|
+
// Fork the relay as a detached background process and return once it's healthy
|
|
258
|
+
// (or we give up waiting). The CLI then exits, so the agent's shell command
|
|
259
|
+
// completes and surfaces this output while the relay keeps running.
|
|
260
|
+
async function startDetached({ port, page, env, resolved }) {
|
|
261
|
+
stopDaemon({ silent: true }); // replace any prior daemon (port clash / dupes)
|
|
262
|
+
|
|
263
|
+
const logPath = join(CWD, ".refine-relay.log");
|
|
264
|
+
let out = "ignore";
|
|
265
|
+
try { out = openSync(logPath, "a"); } catch {}
|
|
266
|
+
const child = spawn(process.execPath, [join(PKG_ROOT, "server/relay.mjs")], {
|
|
267
|
+
detached: true,
|
|
268
|
+
stdio: ["ignore", out, out],
|
|
269
|
+
env,
|
|
270
|
+
});
|
|
271
|
+
child.unref();
|
|
272
|
+
|
|
273
|
+
const llmWired = Boolean(env.REFINE_AGENT_CMD);
|
|
274
|
+
const record = {
|
|
275
|
+
pid: child.pid,
|
|
276
|
+
port: Number(port),
|
|
277
|
+
page: page || null,
|
|
278
|
+
log: logPath,
|
|
279
|
+
version: PKG_VERSION,
|
|
280
|
+
agentCmd: env.REFINE_AGENT_CMD || null,
|
|
281
|
+
startedAt: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
try { writeFileSync(daemonFile(), JSON.stringify(record, null, 2) + "\n"); } catch {}
|
|
284
|
+
|
|
285
|
+
const healthy = await waitForHealth(port, 6000);
|
|
286
|
+
log("");
|
|
287
|
+
if (healthy) {
|
|
288
|
+
log(`✓ relay running in background (pid ${child.pid}) — http://localhost:${port}`);
|
|
289
|
+
} else {
|
|
290
|
+
log(`! relay started (pid ${child.pid}) but wasn't healthy within 6s — check ${rel(logPath)}`);
|
|
291
|
+
}
|
|
292
|
+
if (llmWired) {
|
|
293
|
+
log("✓ agent wired — Refine answers jobs itself (no /refine live loop needed).");
|
|
294
|
+
} else {
|
|
295
|
+
log(`• agent NOT wired${resolved && resolved.reason ? ` — ${resolved.reason}` : ""}.`);
|
|
296
|
+
log(" LLM jobs fall back to a /refine live poller. To wire a persistent agent,");
|
|
297
|
+
log(" re-run with --agent <cursor|claude|codex> (that CLI must be on PATH).");
|
|
298
|
+
}
|
|
299
|
+
log("");
|
|
300
|
+
log("Next:");
|
|
301
|
+
log(" 1. Open your app and hard-refresh — the timeline panel is on the page.");
|
|
302
|
+
log(" 2. Click Refine.");
|
|
303
|
+
log(` Stop it (and remove the injected tag): npx transitions-refine stop`);
|
|
304
|
+
log(` Logs: ${rel(logPath)}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function cmdLive(args) {
|
|
268
308
|
const port = String(args.port || process.env.REFINE_RELAY_PORT || 7331);
|
|
269
309
|
const page = findPage(args.page);
|
|
270
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
|
+
|
|
271
325
|
// 1) inject the script tag
|
|
272
326
|
if (page) {
|
|
273
327
|
injectTag(page, port);
|
|
@@ -295,6 +349,8 @@ function cmdLive(args) {
|
|
|
295
349
|
const wantLlm = Boolean(args.llm);
|
|
296
350
|
const forceKey = typeof args.agent === "string" ? args.agent : null;
|
|
297
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;
|
|
298
354
|
const resolved = resolveAgent({ wantLlm, forceKey });
|
|
299
355
|
if (resolved.cmd) {
|
|
300
356
|
env.REFINE_AGENT_CMD = resolved.cmd;
|
|
@@ -312,6 +368,18 @@ function cmdLive(args) {
|
|
|
312
368
|
log(`• LLM not wired — ${resolved.reason}.`);
|
|
313
369
|
}
|
|
314
370
|
|
|
371
|
+
// 2.75) In a one-shot agent shell (Claude Code / Codex / Cursor tool calls),
|
|
372
|
+
// stdout isn't a TTY and the command is expected to exit. Run the relay
|
|
373
|
+
// as a detached daemon and return, so the agent gets output + a persistent
|
|
374
|
+
// relay. A real interactive terminal (TTY) keeps the foreground Ctrl-C UX.
|
|
375
|
+
// Override either way with --detach / --foreground.
|
|
376
|
+
const inAgentShell = !process.stdout.isTTY;
|
|
377
|
+
const detach = Boolean(args.detach) || (inAgentShell && !args.foreground);
|
|
378
|
+
if (detach) {
|
|
379
|
+
await startDetached({ port, page, env, resolved });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
315
383
|
// 3) start the relay (foreground; Ctrl-C stops it + reverts the injection)
|
|
316
384
|
const relay = spawn(process.execPath, [join(PKG_ROOT, "server/relay.mjs")], {
|
|
317
385
|
stdio: "inherit",
|
|
@@ -354,21 +422,28 @@ function cmdLive(args) {
|
|
|
354
422
|
}
|
|
355
423
|
|
|
356
424
|
function cmdStop(args) {
|
|
357
|
-
|
|
425
|
+
// 1) kill a background daemon if one is recorded for this project. Prefer the
|
|
426
|
+
// page the daemon injected (so we clean the right file even without --page).
|
|
427
|
+
let rec = null;
|
|
428
|
+
try { rec = JSON.parse(readFileSync(daemonFile(), "utf8")); } catch {}
|
|
429
|
+
const killed = stopDaemon();
|
|
430
|
+
|
|
431
|
+
// 2) strip the injected tag.
|
|
432
|
+
const page = findPage(args.page) || (rec && rec.page) || null;
|
|
358
433
|
if (!page || !existsSync(page)) {
|
|
359
|
-
log("! no HTML entry found to clean. Pass --page <path> if needed.");
|
|
434
|
+
if (!killed) log("! no HTML entry found to clean. Pass --page <path> if needed.");
|
|
360
435
|
return;
|
|
361
436
|
}
|
|
362
437
|
const html = readFileSync(page, "utf8");
|
|
363
438
|
if (!html.includes(MARK_START)) {
|
|
364
|
-
log(`nothing to remove in ${page
|
|
439
|
+
if (!killed) log(`nothing to remove in ${rel(page)}`);
|
|
365
440
|
return;
|
|
366
441
|
}
|
|
367
442
|
writeFileSync(page, stripTag(html));
|
|
368
|
-
log(`✓ removed injected tag from ${page
|
|
443
|
+
log(`✓ removed injected tag from ${rel(page)}`);
|
|
369
444
|
}
|
|
370
445
|
|
|
371
|
-
function main() {
|
|
446
|
+
async function main() {
|
|
372
447
|
const args = parseArgs(process.argv.slice(2));
|
|
373
448
|
const cmd = args._[0] || "live";
|
|
374
449
|
if (cmd === "live") return cmdLive(args);
|
|
@@ -377,11 +452,16 @@ function main() {
|
|
|
377
452
|
log(" npx transitions-refine live # inject panel + relay; auto-wire your agent CLI");
|
|
378
453
|
log(" npx transitions-refine live --agent claude # force an agent: cursor | claude | codex");
|
|
379
454
|
log(" npx transitions-refine live --llm # install the Cursor CLI if no agent is found");
|
|
380
|
-
log(" npx transitions-refine stop # remove the injected tag");
|
|
455
|
+
log(" npx transitions-refine stop # stop the relay + remove the injected tag");
|
|
381
456
|
log("");
|
|
382
457
|
log("Options: --page <html> --port <n> --agent <cursor|claude|codex> --llm");
|
|
383
|
-
log("
|
|
384
|
-
log("the
|
|
458
|
+
log(" --detach run the relay in the background and exit (default in agent shells)");
|
|
459
|
+
log(" --foreground keep the relay in the foreground (Ctrl-C to stop)");
|
|
460
|
+
log("");
|
|
461
|
+
log("In a coding agent (Claude Code / Codex / Cursor) `live` auto-detaches: it starts");
|
|
462
|
+
log("a background relay, wires that agent's CLI, and exits — so just running the command");
|
|
463
|
+
log("works. It prefers the agent hosting this run so Refine uses the subscription you");
|
|
464
|
+
log("already have. Or set REFINE_AGENT_CMD to wire any CLI.");
|
|
385
465
|
process.exit(cmd ? 1 : 0);
|
|
386
466
|
}
|
|
387
467
|
|
|
@@ -400,7 +480,11 @@ function isCliEntry() {
|
|
|
400
480
|
return resolve(process.argv[1]) === resolve(self);
|
|
401
481
|
}
|
|
402
482
|
if (isCliEntry()) {
|
|
403
|
-
main()
|
|
483
|
+
main().catch((e) => {
|
|
484
|
+
log(`! refine failed: ${e && e.message ? e.message : e}`);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
});
|
|
404
487
|
}
|
|
405
488
|
|
|
406
|
-
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).");
|