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 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
- // Detection is by each host's env markers; override with --agent <name> or by
124
- // exporting REFINE_AGENT_CMD yourself.
125
- const envHasPrefix = (p) => Object.keys(process.env).some((k) => k.startsWith(p));
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. Returns either
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
- if (process.env.REFINE_AGENT_CMD) {
224
- return { cmd: process.env.REFINE_AGENT_CMD, source: "env" };
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
- let target = null;
228
- if (forceKey) {
229
- target = AGENTS.find((a) => a.key === forceKey) || null;
230
- if (!target) {
231
- return { cmd: null, reason: `unknown --agent "${forceKey}" (use cursor | claude | codex)` };
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
- // No host detected (plain terminal): use any installed agent, in list order.
250
- for (const a of AGENTS) {
251
- const bin = findBin(a);
252
- if (bin) return { cmd: a.cmd(bin), agent: a, source: "scan" };
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
- // Nothing installed: only cursor-agent can be fetched non-interactively.
256
- if (wantLlm) {
257
- const bin = installCursorCli();
258
- if (bin) return { cmd: AGENTS[0].cmd(bin), agent: AGENTS[0], source: "install" };
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 { cmd: null, reason: "no agent CLI found" };
242
+ return false;
261
243
  }
262
244
 
263
- function log(msg) {
264
- process.stdout.write(`${msg}\n`);
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
- function cmdLive(args) {
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
- const page = findPage(args.page);
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.replace(CWD + "/", "")}`);
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.replace(CWD + "/", "")}`);
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("It prefers the agent hosting this run (Cursor/Claude Code/Codex) so Refine uses");
384
- log("the subscription you already have. Or set REFINE_AGENT_CMD to wire any CLI.");
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 { AGENTS, HOST_PRECEDENCE, detectHostAgent, resolveAgent, findBin };
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
- return cliMissing
2667
- ? h("p",{className:"tl-gate-text"},"Install an agent CLI, then run ",
2668
- h("code",{className:"tl-code"},"npx transitions-refine live"),
2669
- " (recommended) or ",h("code",{className:"tl-code"},"/refine live")," in your editor.")
2670
- : h("p",{className:"tl-gate-text"},
2671
- "Run ",h("code",{className:"tl-code"},"npx transitions-refine live"),
2672
- " in your project to start the relay with a persistent agent — works for hours, no chat loop. Or run ",
2673
- h("code",{className:"tl-code"},"/refine live"),
2674
- " in your editor if the relay is already up without an agent wired.");
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.18",
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
- // A bare `cursor-agent` goes interactive: it prints "⚠ Workspace Trust Required"
44
- // and exits 1, so every headless refine/scan/apply job fails. Force the headless
45
- // trio whenever the command is cursor-agent: -p (print/headless, reads the prompt
46
- // from stdin), --trust (trust the workspace without prompting; only valid with
47
- // --print), and --force (auto-allow tool calls so apply/scan don't hang on
48
- // approval). Append only the missing flags; leave non-cursor-agent commands alone.
49
- function augmentAgentCmd(cmd) {
50
- if (!cmd) return cmd;
51
- // `codex exec` reads the prompt from stdin ONLY when given a trailing `-`
52
- // (the stdin marker). The relay always pipes the prompt on stdin, so without
53
- // it Codex gets no prompt and exits 1. Append `-` when it's missing (a lone
54
- // `-` token, not the dashes inside flags like `--sandbox`).
55
- if (/(^|\s|\/)codex(\s|$)/.test(cmd) && /(^|\s)exec(\s|$)/.test(cmd)) {
56
- return /(^|\s)-(\s|$)/.test(cmd) ? cmd : `${cmd} -`;
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
- if (!/(^|\s|\/)cursor-agent(\s|$)/.test(cmd)) return cmd;
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 AGENT_CMD = augmentAgentCmd(process.env.REFINE_AGENT_CMD || null);
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(AGENT_CMD) || pollerActive();
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 (AGENT_CMD) return true;
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(AGENT_CMD),
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 (AGENT_CMD) {
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 (AGENT_CMD) {
867
- console.log(` LLM jobs answered by spawning: ${AGENT_CMD}`);
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).");