flowviant 0.33.0 → 0.34.2

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
@@ -42,11 +42,37 @@ import { runWorker, runStaticFleet } from './lib/single.mjs';
42
42
  import { runLogin } from './lib/login.mjs';
43
43
  import { preflight } from './lib/preflight.mjs';
44
44
 
45
- // `flowviant login` — device auth (recommended): approve a code in the
46
- // app, the credential is stored locally, then plain `flowviant` just runs.
45
+ // `flowviant login` — device auth (recommended): approve a code in the app, the
46
+ // credential is stored locally, and then we KEEP GOING into the daemon.
47
+ //
48
+ // It used to print "Now just run: npx flowviant" and exit. Everything about that
49
+ // was technically correct and practically a dead end: the line scrolled past in
50
+ // a terminal the user had already stopped reading (they were in the browser,
51
+ // typing a code), and the app told them their machine would "come online
52
+ // shortly" — which it never did, because nothing was running. The product
53
+ // promise is install once; a second command you have to notice is not that.
54
+ //
55
+ // `--no-start` for scripts and CI, which want the credential and not a
56
+ // long-running process.
47
57
  if (process.argv[2] === 'login') {
48
- await runLogin();
49
- process.exit(0);
58
+ const noStart = process.argv.includes('--no-start');
59
+ await runLogin({ thenStart: !noStart });
60
+ if (noStart) process.exit(0);
61
+ // Re-exec as a plain `flowviant` rather than falling through. config.mjs reads
62
+ // the credential at IMPORT time — which was before the login we just did — so
63
+ // this process still has an empty FLEET_TOKEN and would exit with "no
64
+ // credential found" seconds after saving one. Same shape as the self-update
65
+ // re-exec: stay alive as a thin proxy so the user's shell keeps one foreground
66
+ // process.
67
+ const { spawn } = await import('node:child_process');
68
+ const child = spawn(process.execPath, [process.argv[1]], {
69
+ stdio: 'inherit',
70
+ env: process.env,
71
+ });
72
+ // AWAIT it. Registering an exit handler and falling through would run the rest
73
+ // of this file in the parent — which has no credential — and print "no
74
+ // credential found" over the daemon that just started in the child.
75
+ process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
50
76
  }
51
77
 
52
78
  // `flowviant update` — install the latest published version now. The daemon also
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { homedir } from 'node:os';
6
+ import { homedir, cpus } from 'node:os';
7
7
 
8
8
  // Read the daemon's version from its OWN package.json (always shipped in the npm
9
9
  // tarball) — never hardcode it. The hardcoded constant drifted: it sat at
@@ -58,6 +58,32 @@ export const STREAM_URL =
58
58
  process.env.FLOWVIANT_STREAM_URL ||
59
59
  FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
60
60
  export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
61
+
62
+ /**
63
+ * How many tasks THIS MACHINE will build at once.
64
+ *
65
+ * The limit belongs here, not on the server: a task in flight is a Claude Code
66
+ * session plus its own git worktree plus whatever the project's dev server and
67
+ * tests want, and this process is the only party that can see the cores, the
68
+ * RAM and the fan. The server used to decide it, indirectly, by how many lanes
69
+ * a user had pre-sized with a dial — which asked them to answer a question
70
+ * about their laptop in a web app, before they knew what they were going to
71
+ * dispatch.
72
+ *
73
+ * Sent to the server on every roster poll so it can grow lanes to meet waiting
74
+ * work UNDER this ceiling, and enforced locally besides — the roster can carry
75
+ * more lanes than this (someone added capacity by hand, or a second machine
76
+ * shares the fleet), and a ceiling that only exists as a request is not one.
77
+ *
78
+ * Half the cores, floor 1, cap 4. Half because a build agent is not the only
79
+ * thing running — the user is working on this machine too — and 4 because past
80
+ * that the shared Claude account, not the CPU, is what runs out.
81
+ */
82
+ export const MAX_CONCURRENT = (() => {
83
+ const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
84
+ if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
85
+ return Math.max(1, Math.min(4, Math.floor((cpus().length || 2) / 2)));
86
+ })();
61
87
  export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
62
88
  // Live mode: after this long idle-parked on a blocker, tear the session down to
63
89
  // free the Claude process (the intent stays claimed; it resumes when answered).
package/bin/lib/fleet.mjs CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  MCP_URL,
19
19
  SAFE,
20
20
  POLL_SECONDS,
21
+ MAX_CONCURRENT,
21
22
  IDLE_SECONDS,
22
23
  RECONCILE_SECONDS,
23
24
  REFRESH_BEFORE_SECONDS,
@@ -76,6 +77,11 @@ import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
76
77
  async function fetchRoster(haveIds) {
77
78
  const url = new URL(FLEET_URL);
78
79
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
80
+ // What this machine will run at once. The server grows lanes to meet waiting
81
+ // work beneath this, instead of the user pre-sizing a pool by hand — only the
82
+ // machine knows its cores, its RAM and whose Claude quota is being spent.
83
+ // Older servers ignore the param, so sending it is always safe.
84
+ url.searchParams.set('capacity', String(MAX_CONCURRENT));
79
85
  // Env-sync identity + materialized version (the Settings "env vN" chip).
80
86
  try {
81
87
  for (const [k, v] of Object.entries(await envQueryParams())) {
@@ -1170,6 +1176,7 @@ export async function runFleetDaemon() {
1170
1176
  let connected = false; // log the first successful poll once
1171
1177
  let rosterSig = null; // last roster membership, to log changes only
1172
1178
  let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
1179
+ let cappedWarned = false; // say once, not every reconcile, why extra lanes idle
1173
1180
  let joinCount = 0; // for stable per-agent label colours
1174
1181
 
1175
1182
  // ── Push channel: a server wake short-circuits the reconcile sleep so a job is
@@ -1296,6 +1303,23 @@ export async function runFleetDaemon() {
1296
1303
  }
1297
1304
  hasWorkByAgent.set(a.agentId, !!a.hasWork);
1298
1305
  if (!workers.has(a.agentId)) {
1306
+ // Local ceiling, enforced and not merely requested. The roster can carry
1307
+ // more lanes than this machine asked for — someone added capacity by
1308
+ // hand, or a second machine shares the fleet — and each extra worker is
1309
+ // another Claude session, another worktree and another dev server on
1310
+ // somebody's laptop. Skipping the spawn does NOT strand the work: an
1311
+ // @mention addresses the FLEET, so any running lane can claim it; the
1312
+ // tasks queue behind the ones we did start.
1313
+ if (workers.size >= MAX_CONCURRENT) {
1314
+ if (!cappedWarned) {
1315
+ cappedWarned = true;
1316
+ info(
1317
+ `running ${MAX_CONCURRENT} task${MAX_CONCURRENT === 1 ? '' : 's'} at a time on this machine — ` +
1318
+ `more will queue (FLOWVIANT_MAX_CONCURRENT to change)`
1319
+ );
1320
+ }
1321
+ continue;
1322
+ }
1299
1323
  const wt = join(baseDir, `agent-${a.agentId}`);
1300
1324
  try {
1301
1325
  if (!existsSync(wt)) {
package/bin/lib/live.mjs CHANGED
@@ -138,10 +138,15 @@ This IS your handover, so make it tangible; match the evidence to what you built
138
138
  • UI / any visible screen → attach a real SCREENSHOT. Start the app's dev server
139
139
  in your worktree, then capture it headlessly with
140
140
  \`flowviant shot http://localhost:<PORT>/<route> --out shot.png\` (it finds a
141
- browser for you and never needs a display), and attach_evidence with kind
142
- "screenshot" and the file's base64 (\`base64 -w0 shot.png\`). Shoot EVERY key
143
- screen you changed. If \`flowviant shot\` reports that no browser is available,
144
- do NOT block fall back to the text evidence below.
141
+ browser for you and never needs a display). THEN READ shot.png BACK AND LOOK
142
+ AT IT before you attach you can see images, and this is the only moment
143
+ anyone checks the thing you are about to call proof. A blank page, a 404, an
144
+ error overlay, a collapsed layout and the screen you meant all look identical
145
+ as a file path. If it is wrong, fix the code and shoot again; if it is right,
146
+ attach_evidence with kind "screenshot" and the file's base64
147
+ (\`base64 -w0 shot.png\`). Shoot EVERY key screen you changed. If
148
+ \`flowviant shot\` reports that no browser is available, do NOT block — fall
149
+ back to the text evidence below.
145
150
  • backend / API work → a request/response capture or a data sample showing the
146
151
  write (kind "request_response" or "sample").
147
152
  • a multi-step FLOW (login, signup, checkout): one screenshot does NOT prove it
package/bin/lib/login.mjs CHANGED
@@ -45,7 +45,7 @@ async function post(url, body) {
45
45
  return j.data ?? j;
46
46
  }
47
47
 
48
- export async function runLogin() {
48
+ export async function runLogin({ thenStart = false } = {}) {
49
49
  console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
50
50
  let start;
51
51
  try {
@@ -72,7 +72,14 @@ export async function runLogin() {
72
72
  if (poll.status === 'approved') {
73
73
  store({ fleetToken: poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
74
74
  ok('connected — credential saved to ~/.flowviant/credentials.json');
75
- console.log(`\n Now just run: ${c.bold('npx flowviant')}\n`);
75
+ // The daemon starts right here unless the caller opted out; telling
76
+ // someone to run a second command was the step that got missed, since by
77
+ // this point they are looking at the browser, not this terminal.
78
+ console.log(
79
+ thenStart
80
+ ? `\n ${c.dim('starting your agent — leave this running')}\n`
81
+ : `\n Now run: ${c.bold('npx flowviant')}\n`
82
+ );
76
83
  return;
77
84
  }
78
85
  if (poll.status === 'expired') {
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { spawn, execFileSync } from 'node:child_process';
31
+ import { createServer } from 'node:net';
31
32
  import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, readdirSync, rmSync } from 'node:fs';
32
33
  import { join } from 'node:path';
33
34
  import { homedir, platform, arch } from 'node:os';
@@ -216,6 +217,33 @@ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
216
217
  // Where a dev server announces it bound — "Local: http://localhost:3001/".
217
218
  const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
218
219
 
220
+ /**
221
+ * A port nobody is on right now — bind :0, read what the kernel handed us, let
222
+ * it go. Inherently a RACE (someone could take it in the gap), which is fine:
223
+ * this is a hint passed as $PORT, and the tunnel still aims at whatever the
224
+ * server ACTUALLY announces. Worst case we lose the hint and land back on
225
+ * today's behaviour.
226
+ *
227
+ * Why this exists: previews only survived concurrency by luck. vite/next hop to
228
+ * the next free port when theirs is taken, so two tasks in one repo happened to
229
+ * work. Anything that binds a FIXED port and exits on EADDRINUSE — the `api`
230
+ * preview kind, an explicit `port` in preview.json, a plain app.listen(3000) —
231
+ * had its second preview die outright, reported as "dev server exited before it
232
+ * was reachable" with the real cause buried in the tail. That's not an edge
233
+ * case on a machine running up to MAX_CONCURRENT tasks, and it stops being one
234
+ * at all once the box is shared.
235
+ */
236
+ async function freePort() {
237
+ return new Promise((resolve) => {
238
+ const srv = createServer();
239
+ srv.once('error', () => resolve(null)); // no port to be had — fall through
240
+ srv.listen(0, '127.0.0.1', () => {
241
+ const p = srv.address()?.port ?? null;
242
+ srv.close(() => resolve(p));
243
+ });
244
+ });
245
+ }
246
+
219
247
  // ── Orphan reaping ─────────────────────────────────────────────────────────
220
248
  // Preview children (dev server + tunnel) are detached so we can kill the whole
221
249
  // group — but that also means they SURVIVE an ungraceful daemon death (SIGKILL,
@@ -307,6 +335,10 @@ export async function startPreview({
307
335
  }) {
308
336
  const cf = await ensureCloudflared(log);
309
337
  if (!cf) return null; // fall back to captured evidence
338
+ // Ask for a port nobody's on, and TELL the dev server about it (below) rather
339
+ // than hoping its framework hops. Null if we couldn't get one — everything
340
+ // downstream then behaves exactly as before.
341
+ const bindPort = await freePort();
310
342
  // Host the origin sees. Default 'localhost' (what a local browser sends) so
311
343
  // dev servers that validate Host — Vite server.allowedHosts, webpack, Next's
312
344
  // allowedDevOrigins — accept the tunnel. `hostHeader: false` in preview.json
@@ -320,10 +352,18 @@ export async function startPreview({
320
352
  // a reused/uncleaned worktree still previews. Harmless to tools that ignore
321
353
  // these vars; BROWSER=none stops any auto-open. A repo's preview.json `env`
322
354
  // is layered last, so it can override any of these.
355
+ // PORT is a HINT, deliberately: honoured by Next, Remix, Nuxt, CRA and any
356
+ // conventional `app.listen(process.env.PORT)`, ignored by Vite (which uses
357
+ // server.port and hops on its own) and overridden by an explicit --port in
358
+ // the dev script. All three outcomes are fine — the tunnel aims at the port
359
+ // the server ANNOUNCES, not at what we asked for, so a disregarded hint
360
+ // costs nothing and an honoured one is what makes two concurrent previews
361
+ // of one repo possible. Placed BEFORE extraEnv so preview.json still wins.
323
362
  const env = {
324
363
  ...process.env,
325
364
  VINEXT_NO_DEV_LOCK: '1',
326
365
  BROWSER: 'none',
366
+ ...(bindPort ? { PORT: String(bindPort) } : null),
327
367
  ...(extraEnv && typeof extraEnv === 'object' ? extraEnv : {}),
328
368
  };
329
369
  // detached so each gets its own process group — `bun run dev` via a shell
@@ -444,9 +484,11 @@ export async function startPreview({
444
484
  finish(null);
445
485
  });
446
486
 
447
- // If the server never prints a URL we recognize (quiet server), tunnel to the
448
- // configured port as a last resort.
449
- bindTimer = setTimeout(() => void openTunnel(port), 30_000);
487
+ // If the server never prints a URL we recognize (quiet server), guess. Prefer
488
+ // the port we HANDED it over the one we inferred from its framework: a server
489
+ // quiet enough to reach this line is usually a plain node/express one, and
490
+ // those are exactly the ones that read $PORT.
491
+ bindTimer = setTimeout(() => void openTunnel(bindPort ?? port), 30_000);
450
492
  timer = setTimeout(() => {
451
493
  log?.(
452
494
  `preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.33.0",
3
+ "version": "0.34.2",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — 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": {