flowviant 0.38.0 → 0.39.0

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/lib/live.mjs CHANGED
@@ -44,12 +44,25 @@ import {
44
44
  clearWip,
45
45
  } from './git.mjs';
46
46
  import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
47
+ import { RUNTIMES } from './runtimes.mjs';
47
48
  import { loadPreviewConfig, startPreview } from './preview.mjs';
48
49
  import { materializeInto, scrub as envScrub } from './env.mjs';
49
50
 
50
51
  // Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
51
52
  // reviewer then drives it via "Open live preview" in the node.
52
53
  const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
54
+
55
+ /**
56
+ * What a live session can build, sent on every claim.
57
+ *
58
+ * Read off the registry rather than written as `['claude']` so that the day a
59
+ * second runtime gains an SDK session, marking `live: true` there is the whole
60
+ * change — a hand-written list here would keep withholding its work and the
61
+ * reason would be three files away from the flag that looks like it decides.
62
+ */
63
+ const LIVE_RUNTIMES = Object.values(RUNTIMES)
64
+ .filter((r) => r.live === true && r.mcp && r.args)
65
+ .map((r) => r.id);
53
66
  // Short TTL + a heartbeat that re-asserts while the tunnel is alive. So a live
54
67
  // preview stays linked indefinitely (survives long reviews), but one whose
55
68
  // daemon DIED ungracefully (no more heartbeats) drops off the card within the
@@ -568,7 +581,24 @@ export async function runLiveTask({
568
581
  sampleDiffstat,
569
582
  agentId,
570
583
  }) {
571
- const claim = await mcpCall(mcpUrl, token, 'claim_next_task', {}).catch(() => null);
584
+ // SAY WHAT THIS WORKER CAN DRIVE. The claim is UNPINNED this worker asks for
585
+ // whatever is next rather than for a named task — and that is deliberate (the
586
+ // roster hint is a prediction made before anything is claimed, so pinning to it
587
+ // would sometimes pin to the wrong task). The cost of not pinning is that the
588
+ // server decides, and until it was told, it decided using the MACHINE's
589
+ // capability report: on a box with Codex installed it would hand a
590
+ // codex-addressed task to this worker, which drives the Anthropic Agent SDK
591
+ // and nothing else, and Claude would build it. Nobody was told. The @mention is
592
+ // the only dispatch in this product, and silently answering it with a different
593
+ // CLI is the same class of bug as dispatching from the wrong surface.
594
+ //
595
+ // A live session is Claude-only by construction, not by configuration — it is
596
+ // an SDK, not a subprocess — so this list is exactly the runtimes marked
597
+ // `live` in the registry. An older server ignores the argument and behaves as
598
+ // before; that degrade is what `daemon:min` is for.
599
+ const claim = await mcpCall(mcpUrl, token, 'claim_next_task', {
600
+ runtimes: LIVE_RUNTIMES,
601
+ }).catch(() => null);
572
602
  if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
573
603
  const { runId, intentId } = claim;
574
604
  const brief = claim.brief ?? {};
@@ -7,6 +7,7 @@
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { ok, warn, info, c } from './ui.mjs';
9
9
  import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
10
+ import { RUNTIMES, detectRuntimes } from './runtimes.mjs';
10
11
 
11
12
  function present(cmd) {
12
13
  try {
@@ -28,28 +29,56 @@ function ghAuthed() {
28
29
 
29
30
  /** Prints a checklist and, when a missing prereq is auto-installable, OFFERS to
30
31
  * install it (consent-based, never silent). Returns false only if a *fatal*
31
- * prereq (claude, or git when worktrees are used) is still missing after. */
32
+ * prereq (SOME drivable runtime, or git when worktrees are used) is still
33
+ * missing after. */
32
34
  export async function preflight({ needGit = true } = {}) {
33
35
  addLocalBinToPath(); // find a gh/cloudflared we bundled on a previous run
34
- let claude = present('claude');
35
36
  let gh = present('gh');
36
37
  const node18 = Number(process.versions.node.split('.')[0]) >= 18;
37
38
  const git = needGit ? present('git') : true;
38
39
 
39
40
  info('checking your setup (this tool drives these — it never sees their logins):');
40
41
 
41
- // claude — fatal. Offer the official npm install (no-default: it's bigger and
42
- // account-coupled, so we don't push it).
43
- if (claude) {
44
- ok(`claude installed ${c.dim('· must be signed in run `claude` once if you haven’t')}`);
45
- } else {
46
- warn('claude NOT found Claude Code is required.');
47
- if (await promptYesNo('Install Claude Code now?', false)) {
48
- if (installClaude((m) => info(m))) claude = present('claude');
42
+ // THE RUNTIMES. This used to test one binary — `present('claude')` and treat
43
+ // its absence as fatal, which stopped being right the moment a second CLI could
44
+ // build a task: a machine with Codex and no Claude Code is a working machine,
45
+ // and it was refused at startup. What is fatal is having NOTHING to build with.
46
+ //
47
+ // Reported per runtime rather than as a single verdict, because the three
48
+ // states a user can be in need three different sentences: not installed
49
+ // (install it), installed but this daemon cannot drive it here (the live-mode
50
+ // note, or the registry's own `blocked` reason), and ready.
51
+ let detected = detectRuntimes({ refresh: true });
52
+ const shown = detected.filter((r) => r.installed || r.id === 'claude');
53
+ for (const r of shown) {
54
+ const rt = RUNTIMES[r.id];
55
+ if (r.dispatchable) {
56
+ ok(
57
+ `${rt.bin} installed ${c.dim(`· ${rt.label} · must be signed in — run \`${rt.login}\` once if you haven’t`)}`
58
+ );
59
+ } else if (r.installed) {
60
+ warn(`${rt.bin} installed but not usable here ${c.dim(`· ${r.blocked ?? 'unsupported'}`)}`);
61
+ } else if (r.id === 'claude') {
62
+ // Only Claude gets an install offer: it is the one whose installer we
63
+ // control and can verify. The others are named, not fetched.
64
+ warn('claude NOT found.');
65
+ if (await promptYesNo('Install Claude Code now?', false)) {
66
+ if (installClaude((m) => info(m))) detected = detectRuntimes({ refresh: true });
67
+ }
68
+ if (!detected.find((d) => d.id === 'claude')?.installed) {
69
+ warn('install Claude Code manually: https://claude.com/claude-code');
70
+ }
71
+ }
72
+ }
73
+ const drivable = detected.filter((r) => r.dispatchable);
74
+ // Name the alternatives once, and only when there is nothing to build with —
75
+ // a working machine does not need a catalogue of the CLIs it is not using.
76
+ if (drivable.length === 0) {
77
+ for (const rt of Object.values(RUNTIMES)) {
78
+ if (!detected.find((d) => d.id === rt.id)?.installed) {
79
+ info(`${c.dim(`or ${rt.label}: ${rt.install}`)}`);
80
+ }
49
81
  }
50
- claude
51
- ? ok('claude installed — run `claude` once to sign in')
52
- : warn('install Claude Code manually: https://claude.com/claude-code');
53
82
  }
54
83
 
55
84
  // gh — needed to open PRs. Offer to fetch the isolated binary (yes-default:
@@ -72,6 +101,8 @@ export async function preflight({ needGit = true } = {}) {
72
101
  node18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} — need 18+`);
73
102
  console.log('');
74
103
 
75
- if (!claude) warn('Without `claude` nothing can run — install + sign in, then restart.');
76
- return claude && git;
104
+ if (drivable.length === 0) {
105
+ warn('No usable coding CLI — install and sign in to one of the above, then restart.');
106
+ }
107
+ return drivable.length > 0 && git;
77
108
  }
@@ -31,7 +31,7 @@ import { execFileSync } from 'node:child_process';
31
31
  import { mkdtempSync, writeFileSync } from 'node:fs';
32
32
  import { tmpdir } from 'node:os';
33
33
  import { join } from 'node:path';
34
- import { SAFE, MODEL, USER_AGENT } from './config.mjs';
34
+ import { SAFE, MODEL, USER_AGENT, LIVE } from './config.mjs';
35
35
 
36
36
  /** Truncate for a one-line activity label. */
37
37
  const oneLine = (s, n = 140) =>
@@ -316,6 +316,29 @@ export const DISPATCHABLE = Object.values(RUNTIMES).filter((r) => r.mcp && r.arg
316
316
 
317
317
  export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
318
318
 
319
+ /**
320
+ * Can the worker this daemon is running actually DRIVE this runtime right now?
321
+ *
322
+ * `dispatchable` answers "is the CLI installed and does this module know how to
323
+ * spawn it" — a fact about the machine. This answers a narrower question about
324
+ * THIS PROCESS, and the two came apart the moment live mode became the default.
325
+ *
326
+ * Live mode does not spawn a CLI at all: it drives the Anthropic Agent SDK
327
+ * in-process, which is why `claude.live` is the only `true` in the registry. So
328
+ * on a machine with Codex installed, `dispatchable` says codex and the live
329
+ * worker still cannot build a codex task — and it was claiming them anyway and
330
+ * building them with Claude, because a claim that names no runtime gets whatever
331
+ * is next in line.
332
+ *
333
+ * Reporting drivability rather than installation is what makes the app's answer
334
+ * true: "this machine cannot drive Codex" is a fact the user can act on (restart
335
+ * with FLOWVIANT_POLL=1, or wait for the live subprocess path), where "Codex is
336
+ * installed" plus a task that never starts is a mystery. Activity, never
337
+ * aspiration.
338
+ */
339
+ export const drivableHere = (rt) =>
340
+ Boolean(rt.mcp && rt.args) && (LIVE ? rt.live === true : true);
341
+
319
342
  // ── Detection ──────────────────────────────────────────────────────────────
320
343
 
321
344
  /**
@@ -355,8 +378,15 @@ export function detectRuntimes({ refresh = false } = {}) {
355
378
  version,
356
379
  // Installed and drivable are different questions, and conflating them is
357
380
  // how a user ends up @mentioning something that silently never starts.
358
- dispatchable: version !== null && Boolean(rt.mcp && rt.args),
359
- blocked: rt.blocked ?? null,
381
+ // THREE questions, in fact see `drivableHere`: the CLI can be installed,
382
+ // and this module can know how to spawn it, and the worker this daemon is
383
+ // running can still be unable to drive it.
384
+ dispatchable: version !== null && drivableHere(rt),
385
+ blocked:
386
+ rt.blocked ??
387
+ (version !== null && Boolean(rt.mcp && rt.args) && !drivableHere(rt)
388
+ ? 'this daemon is in live mode, which drives Claude in-process — run with FLOWVIANT_POLL=1 to build with this CLI'
389
+ : null),
360
390
  };
361
391
  });
362
392
  return detectedCache;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.38.0",
4
- "description": "Run your own coding CLIs as headless build agents for Flowviant \u2014 Claude Code or Codex, on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
3
+ "version": "0.39.0",
4
+ "description": "Run your own coding CLIs as headless build agents for Flowviant Claude Code or Codex, on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowviant": "bin/cli.mjs"