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 +30 -4
- package/bin/lib/config.mjs +27 -1
- package/bin/lib/fleet.mjs +24 -0
- package/bin/lib/live.mjs +9 -4
- package/bin/lib/login.mjs +9 -2
- package/bin/lib/preview.mjs +45 -3
- package/package.json +1 -1
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
|
-
//
|
|
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
|
-
|
|
49
|
-
|
|
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
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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') {
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -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),
|
|
448
|
-
//
|
|
449
|
-
|
|
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.
|
|
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": {
|