flowviant 0.99.0 → 0.101.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/fleet.mjs CHANGED
@@ -39,7 +39,8 @@ import {
39
39
  } from './config.mjs';
40
40
  import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
41
41
  import { credentialRejected } from './authReject.mjs';
42
- import { handleVersionSignal } from './update.mjs';
42
+ import { handleVersionSignal, cmpVersion } from './update.mjs';
43
+ import { emitMachineEvent, writeDaemonState } from './desktopContract.mjs';
43
44
  import {
44
45
  git,
45
46
  gitNetAsync,
@@ -1128,6 +1129,8 @@ export async function standDownDisplaced({
1128
1129
  ? `this project's machine moved to ${name} — standing down.`
1129
1130
  : "this project's machine moved to another box — standing down."
1130
1131
  );
1132
+ if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
1133
+ else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
1131
1134
  // FIRST, and awaited: an unsettled turn is the one thing here that no later
1132
1135
  // poll from anybody can fix — this process holds the only copy of the fact
1133
1136
  // that it was running.
@@ -1160,6 +1163,10 @@ export async function standDownDisplaced({
1160
1163
  // base between tasks (fresh conversation), resume in place while on a blocker.
1161
1164
 
1162
1165
  export async function runFleetDaemon({ afterLock = null } = {}) {
1166
+ let desktopProjectId = CREDENTIAL?.entry?.projectId ?? null;
1167
+ let wasServing = false;
1168
+ if (process.env.FLOWVIANT_UPDATE_TARGET && cmpVersion(VERSION, process.env.FLOWVIANT_UPDATE_TARGET) >= 0)
1169
+ emitMachineEvent({ event: 'update-applied', from: process.env.FLOWVIANT_UPDATE_FROM ?? null, to: VERSION });
1163
1170
  console.log('');
1164
1171
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
1165
1172
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
@@ -1279,6 +1286,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1279
1286
  } catch {
1280
1287
  /* the binding is asked again next time */
1281
1288
  }
1289
+ writeDaemonState(desktopProjectId, { limits: {}, holder: null });
1282
1290
 
1283
1291
  await preflight({ needGit: true });
1284
1292
 
@@ -1443,6 +1451,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1443
1451
  // A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
1444
1452
  };
1445
1453
  process.on('SIGINT', () => {
1454
+ emitMachineEvent({ event: 'stopped', reason: 'signal' });
1446
1455
  console.log('');
1447
1456
  note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1448
1457
  teardown();
@@ -1452,6 +1461,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1452
1461
  // handler every child survived a `systemctl stop` — the exact orphaning the
1453
1462
  // teardown exists to prevent.
1454
1463
  process.on('SIGTERM', () => {
1464
+ emitMachineEvent({ event: 'stopped', reason: 'signal' });
1455
1465
  console.log('');
1456
1466
  note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1457
1467
  teardown();
@@ -2407,6 +2417,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2407
2417
  );
2408
2418
  } catch (e) {
2409
2419
  if (e.auth) {
2420
+ emitMachineEvent({ event: 'stopped', reason: 'credential-revoked' });
2410
2421
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
2411
2422
  teardown();
2412
2423
  // EXIT 0, for the same reason the commanded-stop path does: a revoked
@@ -2467,6 +2478,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2467
2478
  }
2468
2479
  })();
2469
2480
  }
2481
+ if (roster.project?.id) desktopProjectId = roster.project.id;
2482
+ writeDaemonState(desktopProjectId, { lastPoll: new Date().toISOString() });
2470
2483
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
2471
2484
  /**
2472
2485
  * HOW MANY TURNS THE APP SAYS THIS MACHINE MAY RUN (2026-09-17).
@@ -2495,6 +2508,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2495
2508
  // going away.
2496
2509
  const stopSignal = shouldStop(roster.daemon);
2497
2510
  if (stopSignal) {
2511
+ emitMachineEvent({ event: 'stopped', reason: stopSignal.reason ?? null });
2498
2512
  warn(
2499
2513
  stopSignal.reason
2500
2514
  ? `stopped by Flowviant — ${stopSignal.reason}`
@@ -2599,6 +2613,12 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2599
2613
  * would start deleting worktrees it cannot see the tabs for.
2600
2614
  */
2601
2615
  holderState = holderWatch.observe(roster.holder);
2616
+ writeDaemonState(desktopProjectId, {
2617
+ lastPoll: new Date().toISOString(),
2618
+ holder: ({ mine: 'serving', standby: 'inactive' })[holderState] ?? null,
2619
+ });
2620
+ if (holderState === 'mine' && !wasServing) emitMachineEvent({ event: 'serving' });
2621
+ wasServing = holderState === 'mine';
2602
2622
  // Keep the daemon current. Safe = no worker mid-task (true at startup, since
2603
2623
  // no workers are spawned yet). If it self-updates it re-execs into the new
2604
2624
  // version and this process becomes a proxy — stop the loop.
@@ -2620,7 +2640,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2620
2640
  safeToUpdate,
2621
2641
  teardown,
2622
2642
  });
2623
- if (updating) return;
2643
+ if (await updating) return;
2624
2644
  }
2625
2645
  // Settle any turn/ship answers whose earlier report POST failed BEFORE
2626
2646
  // taking new work — the skip-if-pending guards make the ordering safe, but
@@ -139,6 +139,20 @@ function readHolder(path) {
139
139
  }
140
140
  }
141
141
 
142
+ /** Read-only desktop measurement. No lock is unknown because an unguarded or
143
+ * older daemon may still run; a stale lock with a dead pid measures empty. */
144
+ export function daemonRunningFor(fleetToken) {
145
+ const path = instanceLockPath(fleetToken);
146
+ let raw;
147
+ try { raw = readFileSync(path, 'utf8'); }
148
+ catch { return null; }
149
+ let holder;
150
+ try { holder = JSON.parse(raw); } catch { return null; }
151
+ if (!Number.isInteger(holder?.pid) || holder.pid <= 0) return null;
152
+ if (!alive(holder.pid)) return false;
153
+ return stillTheHolder(holder);
154
+ }
155
+
142
156
  /**
143
157
  * argv[1] AS IT WAS TYPED, when that differs from the resolved form.
144
158
  *
@@ -0,0 +1,23 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ /** The updater and terminal advice share one detection of the launch channel. */
4
+ export function runningViaNpx() {
5
+ const ua = process.env.npm_config_user_agent || '';
6
+ const argv1 = process.argv[1] || '';
7
+ let self = '';
8
+ try {
9
+ self = fileURLToPath(import.meta.url);
10
+ } catch {
11
+ /* non-file URL — ignore */
12
+ }
13
+ return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
14
+ }
15
+
16
+ /** Name the command that launched this process when giving a person a next step. */
17
+ export function launchCommand() {
18
+ return 'flowviant';
19
+ }
20
+
21
+ export function terminalCommand(args = '') {
22
+ return `${launchCommand()}${args ? ` ${args}` : ''}`;
23
+ }
package/bin/lib/login.mjs CHANGED
@@ -10,6 +10,7 @@ import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
10
10
  import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
11
11
  import { c, info, ok, warn, fail } from './ui.mjs';
12
12
  import { sleep } from './claude.mjs';
13
+ import { launchCommand, terminalCommand } from './launchCommand.mjs';
13
14
 
14
15
  const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
15
16
  const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
@@ -27,24 +28,35 @@ async function post(url, body) {
27
28
  return j.data ?? j;
28
29
  }
29
30
 
30
- export async function runLogin({ thenStart = false } = {}) {
31
- console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
31
+ export async function runLogin({ thenStart = false, json = false, dir = process.cwd() } = {}) {
32
+ const event = (value) => { if (json) process.stdout.write(`${JSON.stringify(value)}\n`); };
33
+ const selectedRepo = json ? detectRepoRoot(dir) : null;
34
+ if (json && !selectedRepo) { event({ event: 'error', message: `No git repository found in ${dir}.` }); return { saved: false }; }
35
+ if (!json) console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
32
36
  let start;
33
37
  try {
34
38
  start = await post(DEVICE_START, {});
35
39
  } catch (e) {
40
+ if (json) { event({ event: 'error', message: `couldn't reach Flowviant (${e.message}).` }); return { saved: false }; }
36
41
  fail(`couldn't reach Flowviant (${e.message}).`);
37
42
  process.exit(1);
38
43
  }
39
44
  const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
45
+ if (json && (typeof deviceCode !== 'string' || typeof userCode !== 'string' || userCode.length < 8)) {
46
+ event({ event: 'error', message: 'Flowviant returned an invalid login code.' });
47
+ return { saved: false };
48
+ }
40
49
  const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
41
50
  // Where the control ACTUALLY is. It was "the Agents panel", a settings
42
51
  // section deleted 2026-08-17; connecting a machine is offered on the surface
43
52
  // you are on when it matters, and for a new operator that is the Workbench —
44
53
  // the project's empty state says so before it can show you any sessions.
45
- console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
46
- console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
47
- info('waiting for you to approve…');
54
+ if (json) event({ event: 'open_url', url: APP_URL, code: pretty });
55
+ else {
56
+ console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
57
+ console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
58
+ info('waiting for you to approve…');
59
+ }
48
60
 
49
61
  const deadline = Date.now() + expiresInSeconds * 1000;
50
62
  while (Date.now() < deadline) {
@@ -56,6 +68,10 @@ export async function runLogin({ thenStart = false } = {}) {
56
68
  continue; // transient — keep polling
57
69
  }
58
70
  if (poll.status === 'approved') {
71
+ if (json && (!poll.projectId || !(poll.machineToken ?? poll.fleetToken))) {
72
+ event({ event: 'error', message: 'Flowviant did not return a project credential.' });
73
+ return { saved: false };
74
+ }
59
75
  // `machineToken` is the wire's new name; `fleetToken` is the one every
60
76
  // published daemon reads. The server dual-sends until DAEMON_MIN clears
61
77
  // the release that reads the new one (0.54.2+) — reading both here is
@@ -65,7 +81,7 @@ export async function runLogin({ thenStart = false } = {}) {
65
81
  // know for certain which checkout this project means, and the binding is
66
82
  // what lets a multi-project VM resolve `npx flowviant` by DIRECTORY
67
83
  // instead of by whichever login happened last.
68
- const repoRoot = detectRepoRoot();
84
+ const repoRoot = selectedRepo ?? detectRepoRoot(dir);
69
85
  const entry = {
70
86
  fleetToken: poll.machineToken ?? poll.fleetToken,
71
87
  projectId: poll.projectId,
@@ -86,12 +102,18 @@ export async function runLogin({ thenStart = false } = {}) {
86
102
  // (`directoryTakenRefusal`, shared with the daemon's own start).
87
103
  const clash = boundElsewhere(listStoredProjects(), repoRoot, entry.projectId);
88
104
  if (clash.length > 0) {
105
+ if (json) { event({ event: 'error', message: directoryTakenRefusal(clash, repoRoot, { incoming: entry }) }); return { saved: false }; }
89
106
  console.log('');
90
107
  warn(directoryTakenRefusal(clash, repoRoot, { incoming: entry }));
91
108
  console.log('');
92
109
  return { saved: false };
93
110
  }
94
- saveLogin(entry);
111
+ try { saveLogin(entry); }
112
+ catch (e) {
113
+ if (json) { event({ event: 'error', message: `Could not save this connection: ${e.message}` }); return { saved: false }; }
114
+ throw e;
115
+ }
116
+ if (json) { event({ event: 'bound', projectId: entry.projectId, name: entry.name, dir: repoRoot }); return { saved: true }; }
95
117
  ok(
96
118
  `connected to ${c.bold(projectLabel(entry))}` +
97
119
  `${repoRoot ? ` for ${c.dim(repoRoot)}` : ''} — saved to ~/.flowviant/credentials.json`
@@ -102,15 +124,17 @@ export async function runLogin({ thenStart = false } = {}) {
102
124
  console.log(
103
125
  thenStart
104
126
  ? `\n ${c.dim('starting your agent — leave this running')}\n`
105
- : `\n Now run: ${c.bold('npx flowviant')}\n`
127
+ : `\n Now run: ${c.bold(launchCommand())}\n`
106
128
  );
107
129
  return { saved: true };
108
130
  }
109
131
  if (poll.status === 'expired') {
110
- warn('that code expired — run `flowviant login` again.');
132
+ if (json) { event({ event: 'error', message: 'Login code expired.' }); return { saved: false }; }
133
+ warn(`that code expired — run \`${terminalCommand('login')}\` again.`);
111
134
  process.exit(1);
112
135
  }
113
136
  }
114
- warn('login timed out — run `flowviant login` again.');
137
+ if (json) { event({ event: 'error', message: 'Login timed out.' }); return { saved: false }; }
138
+ warn(`login timed out — run \`${terminalCommand('login')}\` again.`);
115
139
  process.exit(1);
116
140
  }
@@ -74,6 +74,7 @@
74
74
  import { USER_AGENT } from './config.mjs';
75
75
  import { projectLabel, repoCollisions, safeName } from './credentials.mjs';
76
76
  import { credentialRejected } from './authReject.mjs';
77
+ import { launchCommand, terminalCommand } from './launchCommand.mjs';
77
78
 
78
79
  /** The boxes read, derived from the roster URL the way the diffstat post is —
79
80
  * one place configures the API base and everything else is a suffix swap. */
@@ -246,7 +247,7 @@ export function renderMachines(entries, results, { now = Date.now() } = {}) {
246
247
  if (r.rejected) {
247
248
  lines.push(
248
249
  ' credential rejected — this project was disconnected or deleted. ' +
249
- `\`flowviant machines --forget ${e.projectId.slice(0, 8)}\` removes it here.`
250
+ `\`${terminalCommand(`machines --forget ${e.projectId.slice(0, 8)}`)}\` removes it here.`
250
251
  );
251
252
  continue;
252
253
  }
@@ -297,7 +298,7 @@ export function renderCollisions(entries) {
297
298
  const list = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
298
299
  return (
299
300
  ` ! ${g.entries.length} projects are connected for ${g.repoRoot}: ${list} — ` +
300
- 'a directory serves one project, and `npx flowviant` there refuses to start until it does. ' +
301
+ `a directory serves one project, and \`${launchCommand()}\` there refuses to start until it does. ` +
301
302
  'Delete the one you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it.'
302
303
  );
303
304
  });
@@ -317,7 +318,7 @@ export function renderCollisions(entries) {
317
318
  */
318
319
  export const MACHINES_FOOTER = [
319
320
  ' stop a machine from the app: project settings → Machine → Disconnect;',
320
- ' a new `npx flowviant` on another box takes the project over.',
321
+ ` a new \`${launchCommand()}\` on another box takes the project over.`,
321
322
  ' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
322
323
  ];
323
324
 
@@ -325,7 +326,7 @@ export const MACHINES_FOOTER = [
325
326
  * terminal that could draw the menu was offered the same two things by name,
326
327
  * and repeating the flags under it would be a manual under a control. */
327
328
  export const MACHINES_FLAGS_FOOTER = [
328
- ' `flowviant machines --remove <id>` disconnects this box from a project: stops its daemon here,',
329
+ ` \`${terminalCommand('machines --remove <id>')}\` disconnects this box from a project: stops its daemon here,`,
329
330
  ' removes this box from that project’s machines list in the app, and forgets its credential here.',
330
331
  ' `--forget <id>` forgets the credential here only.',
331
332
  ];
@@ -339,13 +340,13 @@ export const MACHINES_FLAGS_FOOTER = [
339
340
  * probably forget. Two different next moves; collapsing them is how somebody
340
341
  * deletes a working credential.
341
342
  */
342
- export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch } = {}) {
343
+ export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch, signal = AbortSignal.timeout(15_000) } = {}) {
343
344
  const target = new URL(url);
344
345
  if (envpub) target.searchParams.set('envpub', envpub);
345
346
  try {
346
347
  const res = await fetchImpl(target, {
347
348
  headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
348
- signal: AbortSignal.timeout(15_000),
349
+ signal,
349
350
  });
350
351
  if (await credentialRejected(res)) return { rejected: true };
351
352
  if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
@@ -487,7 +488,7 @@ export async function disconnectHere(entry, deps, { log = (m) => console.log(m)
487
488
  log(`could not forget the credential here: ${gone.error}`);
488
489
  return { ok: false };
489
490
  }
490
- log(`forgot ${who}’s credential on this box. \`flowviant login\` in its repo connects it again.`);
491
+ log(`forgot ${who}’s credential on this box. \`${terminalCommand('login')}\` in its repo connects it again.`);
491
492
  return { ok: true };
492
493
  }
493
494
 
@@ -18,15 +18,16 @@
18
18
  */
19
19
 
20
20
  import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
21
+ import { terminalCommand } from './launchCommand.mjs';
21
22
 
22
23
  const CLI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/cli-token');
23
24
 
24
25
  export async function runMcpCommand(args = []) {
25
26
  if (!FLEET_TOKEN) {
26
27
  console.error(
27
- 'error: no credential resolves here. Run `flowviant login` first — or, with\n' +
28
+ `error: no credential resolves here. Run \`${terminalCommand('login')}\` first — or, with\n` +
28
29
  'several projects connected on this box, run this inside the project\'s own\n' +
29
- 'repo or pass `--project <name|id>` (`flowviant projects` lists them).'
30
+ `repo or pass \`--project <name|id>\` (\`${terminalCommand('projects')}\` lists them).`
30
31
  );
31
32
  process.exit(1);
32
33
  }
@@ -50,7 +51,7 @@ export async function runMcpCommand(args = []) {
50
51
  console.error(
51
52
  `error: could not mint a CLI credential (${res.status}). ` +
52
53
  (res.status === 401 || res.status === 403
53
- ? 'Your credential may have been revoked — try `flowviant login` again.'
54
+ ? `Your credential may have been revoked — try \`${terminalCommand('login')}\` again.`
54
55
  : 'Try again in a moment.')
55
56
  );
56
57
  process.exit(1);
@@ -8,6 +8,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
10
  import { RUNTIMES, detectRuntimes, mediatedSafeGap } from './runtimes.mjs';
11
+ import { terminalCommand } from './launchCommand.mjs';
11
12
 
12
13
  function present(cmd) {
13
14
  try {
@@ -106,7 +107,7 @@ export async function preflight({ needGit = true } = {}) {
106
107
  if (await installGh((m) => info(m))) gh = present('gh');
107
108
  }
108
109
  gh
109
- ? ok('gh installed to ~/.flowviant/bin — authenticate with: flowviant gh-auth')
110
+ ? ok(`gh installed to ~/.flowviant/bin — authenticate with: ${terminalCommand('gh-auth')}`)
110
111
  : info(c.dim('or install it later: https://cli.github.com, then: gh auth login'));
111
112
  }
112
113
 
package/bin/lib/tty.mjs CHANGED
@@ -112,7 +112,7 @@ export function boundedTimer(timeoutMs, fire) {
112
112
  * answer everywhere (the binding confirm serves unbound; the project picker
113
113
  * refuses, exactly as it does headless).
114
114
  */
115
- export async function askWithTimeout(query, timeoutMs) {
115
+ export async function askWithTimeout(query, timeoutMs, output = process.stdout) {
116
116
  const noop = () => {};
117
117
  // Replacing the DEFAULT disposition is the whole point — an empty handler is
118
118
  // enough, and it is what keeps the timer below able to run at all.
@@ -120,7 +120,7 @@ export async function askWithTimeout(query, timeoutMs) {
120
120
  process.on('SIGTTOU', noop);
121
121
  const rl = (await import('node:readline/promises')).createInterface({
122
122
  input: process.stdin,
123
- output: process.stdout,
123
+ output,
124
124
  });
125
125
  const ac = new AbortController();
126
126
  const timer = boundedTimer(timeoutMs, () => ac.abort());