flowviant 0.53.0 → 0.54.1

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/login.mjs CHANGED
@@ -56,7 +56,11 @@ export async function runLogin({ thenStart = false } = {}) {
56
56
  }
57
57
  const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
58
58
  const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
59
- console.log(` 1. Open ${c.cyan(APP_URL)} your project the ${c.bold('Agents')} panel → ${c.bold('Connect a machine')}.`);
59
+ // Where the control ACTUALLY is. It was "the Agents panel", a settings
60
+ // section deleted 2026-08-17; connecting a machine is offered on the surface
61
+ // you are on when it matters, and for a new operator that is the Workbench —
62
+ // the project's empty state says so before it can show you any sessions.
63
+ console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
60
64
  console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
61
65
  info('waiting for you to approve…');
62
66
 
@@ -2,17 +2,18 @@
2
2
  * `flowviant mcp` — connect YOUR Claude to Flowviant so you can file work from
3
3
  * the terminal ("stick that on the board", "file a task for this TODO").
4
4
  *
5
- * This mints a `cli` credential, which is a different principal from the worker
6
- * tokens the daemon rotates for builds. That separation is the point, not
7
- * bookkeeping: a worker reads untrusted repo, PR and issue text all day, so
8
- * giving THAT principal tools that write to your workspace would mean a hostile
9
- * string in a README could file work as you. The cli credential sees only the
10
- * management tools and can never claim or complete work; the worker can never
11
- * reach create_task.
5
+ * This mints a `cli` credential, which is a different principal from the
6
+ * per-session `work` tokens the daemon mints for Workbench tabs. That
7
+ * separation is the point, not bookkeeping: a session's agent reads untrusted
8
+ * repo and issue text all day, so giving THAT principal tools that write to
9
+ * your workspace would mean a hostile string in a README could file work as
10
+ * you. The cli credential sees only the management tools and can never work or
11
+ * ship a card; a session token can never reach create_task.
12
12
  *
13
13
  * There is deliberately no invite capability on it. Invites grant access to a
14
- * paid workspace and are guarded by a human browser session; you ask Flowvy in
15
- * the app for those, and approve the card.
14
+ * paid workspace and are guarded by a human browser session they are sent
15
+ * from the workspace card's gear menu in the app, and never from a CLI
16
+ * credential sitting on a shared machine.
16
17
  */
17
18
 
18
19
  import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
@@ -77,7 +78,7 @@ export async function runMcpCommand(args = []) {
77
78
  console.log(` ${cmd}`);
78
79
  console.log('');
79
80
  console.log('Then, in any Claude session: "file a task in Flowviant for …".');
80
- console.log('Tasks land as drafts — nothing runs until you open one in the');
81
- console.log('app and @mention an agent.');
81
+ console.log('Tasks land as drafts on the board — nothing runs until you open');
82
+ console.log('a session tab in the Workbench and type in it.');
82
83
  console.log('');
83
84
  }
@@ -91,20 +91,23 @@ export async function preflight({ needGit = true } = {}) {
91
91
  }
92
92
  }
93
93
 
94
- // gh — needed to open PRs. Offer to fetch the isolated binary (yes-default:
95
- // low-risk, no login carried by the install itself).
94
+ // gh — OPTIONAL. The daemon pushes and merges with plain `git`; nothing in
95
+ // the product opens a pull request any more, so gh is a convenience for the
96
+ // agent, not a requirement. Hence the offer no longer defaults to yes: an
97
+ // install nobody asked for, defaulted on, is how a preflight installs
98
+ // software on a machine whose operator only wanted a status line.
96
99
  if (gh && ghAuthed()) {
97
100
  ok('gh authenticated');
98
101
  } else if (gh) {
99
102
  warn('gh not signed in — run: gh auth login');
100
103
  } else {
101
- warn('gh NOT found — needed to open PRs.');
102
- if (await promptYesNo('Install GitHub CLI (gh) now?', true)) {
104
+ info('gh not found (optional).');
105
+ if (await promptYesNo('Install GitHub CLI (gh) now?', false)) {
103
106
  if (await installGh((m) => info(m))) gh = present('gh');
104
107
  }
105
108
  gh
106
109
  ? ok('gh installed to ~/.flowviant/bin — authenticate with: flowviant gh-auth')
107
- : warn('install gh manually: https://cli.github.com, then run: gh auth login');
110
+ : info(c.dim('or install it later: https://cli.github.com, then: gh auth login'));
108
111
  }
109
112
 
110
113
  if (needGit) (git ? ok('git installed') : warn('git NOT found — install git'));
@@ -687,9 +687,18 @@ export const RUNTIMES = {
687
687
  // turn reports failure having touched nothing. Caught only because an
688
688
  // end-to-end test had to add the flag by hand to work.
689
689
  //
690
- // NOT `--sandbox` here, unlike wiki: a build has to `git push` and run
691
- // `gh pr create`, so it needs the network by definition. The containment
692
- // is the worktree, as it is for every runtime.
690
+ // NOT `--sandbox` here, unlike wiki: a build has to `git push`, so it
691
+ // needs the network by definition.
692
+ //
693
+ // AND NOTHING ELSE CONTAINS IT EITHER. This comment used to end "the
694
+ // containment is the worktree, as it is for every runtime", which was
695
+ // false and is the kind of false that stops people looking: `cwd` is a
696
+ // starting directory, not a jail. A build turn runs with the operator's
697
+ // full user permissions — it can write outside the worktree, to their
698
+ // home directory, to their other checkouts. Claude gets
699
+ // --dangerously-skip-permissions, codex gets --sandbox
700
+ // danger-full-access, and agy gets no sandbox flag at all. The only
701
+ // real boundary today is that the person driving the tab is trusted.
693
702
  //
694
703
  // FLOWVIANT_SAFE HAS NO EXPRESSION ON THIS RUNTIME. Claude narrows to an
695
704
  // allowlist and Codex to `workspace-write`; agy's only per-invocation
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Fleet daemon push channel — the best-practice endgame for dispatch latency.
2
+ * Machine daemon push channel — the best-practice endgame for pickup latency.
3
3
  *
4
- * Holds a hibernatable WebSocket open to the server. When a job lands (a wiki
5
- * regen, a dispatch, a merge request, an @mention), the server pushes a
6
- * `{type:'wake'}` frame and the daemon reconciles IMMEDIATELY — its normal
7
- * roster fetch — instead of waiting out the poll. That collapses pickup latency
8
- * from ≤RECONCILE_SECONDS to ~a round trip.
4
+ * Holds a hibernatable WebSocket open to the server. When a job lands a
5
+ * session turn typed into a tab, a diff request, a preview, an env sync, a
6
+ * deploy, a wiki regen — the server pushes a `{type:'wake'}` frame and the
7
+ * daemon reconciles IMMEDIATELY — its normal roster fetch — instead of waiting
8
+ * out the poll. That collapses pickup latency from ≤RECONCILE_SECONDS to ~a
9
+ * round trip, which is what makes a message typed in the browser start on the
10
+ * machine at once.
9
11
  *
10
12
  * The socket carries NO authority: it's a dumb nudge, the roster HTTP fetch is
11
13
  * the source of truth (notify-then-reconcile, à la k8s watch). So a dropped or
@@ -74,9 +76,9 @@ export function connectStream({ onWake, isAlive }) {
74
76
  lastRxAt = Date.now();
75
77
  if (announcedDown) {
76
78
  announcedDown = false;
77
- info(c.dim('push channel reconnected — instant dispatch back on'));
79
+ info(c.dim('push channel reconnected — turns start instantly again'));
78
80
  } else {
79
- note(c.dim('push channel connected — instant dispatch on'));
81
+ note(c.dim('push channel connected — turns start instantly'));
80
82
  }
81
83
  clearPing();
82
84
  pingTimer = setInterval(() => {
@@ -114,10 +114,11 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
114
114
 
115
115
  if (wantInstall && !npx) {
116
116
  if (!safeToUpdate) {
117
- // Outdated but an agent is mid-task — wait for idle. Nag once meanwhile.
117
+ // Outdated but a turn is running — wait until the machine is quiet. Nag
118
+ // once meanwhile.
118
119
  if (naggedFor !== target) {
119
120
  naggedFor = target;
120
- note(`flowviant ${cur} → ${target} available — self-updating once agents go idle.`);
121
+ note(`flowviant ${cur} → ${target} available — self-updating once no turn is running.`);
121
122
  }
122
123
  return false;
123
124
  }
package/bin/lib/work.mjs CHANGED
@@ -799,9 +799,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
799
799
  'Content-Type': 'application/json',
800
800
  },
801
801
  signal: AbortSignal.timeout(30_000),
802
- body: JSON.stringify({ sessionId }),
802
+ // The instance is what CLAIMS the session lease server-side. Two
803
+ // daemons share one fleet credential, so the token cannot say which of
804
+ // us is serving this tab — and the mint is the moment that matters:
805
+ // there is one work-token row per session and minting ROTATES it, so a
806
+ // second mint revokes the first daemon's live secret mid-turn.
807
+ body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
803
808
  });
804
809
  if (res.status === 404) return { gone: true };
810
+ // 409 — another daemon on this credential holds the session. Not ours to
811
+ // serve and not a retry: stand down and let the holder answer.
812
+ if (res.status === 409) return { heldElsewhere: true };
805
813
  if (!res.ok) return null;
806
814
  const token = (await res.json().catch(() => null))?.data?.token ?? null;
807
815
  if (!token) return null;
@@ -1168,8 +1176,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1168
1176
  * machines. When the roster omits the field entirely (older server),
1169
1177
  * absence of signal is not a close — retire nothing.
1170
1178
  */
1171
- const retireWorkSessions = (activeIds) => {
1179
+ /** Every session this daemon currently has a worktree for — what renews our
1180
+ * lease on the poll. Read off the directory rather than a map, so it is the
1181
+ * same fact retirement acts on. */
1182
+ const heldSessionIds = () => {
1183
+ const dir = join(baseDir, 'sessions');
1184
+ try {
1185
+ return readdirSync(dir).filter(isSafePathSegment).slice(0, 50);
1186
+ } catch {
1187
+ return [];
1188
+ }
1189
+ };
1190
+
1191
+ const retireWorkSessions = (activeIds, heldElsewhere) => {
1172
1192
  if (!Array.isArray(activeIds)) return;
1193
+ // Sessions ANOTHER daemon on this credential is serving. They are absent
1194
+ // from activeWorkSessions for us and present for them, and removing their
1195
+ // worktree would pull the directory out from under a running turn. Absence
1196
+ // means "the tab closed"; this is the one other thing it can mean.
1197
+ const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
1173
1198
  const dir = join(baseDir, 'sessions');
1174
1199
  if (!existsSync(dir)) return;
1175
1200
  let ids;
@@ -1182,6 +1207,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1182
1207
  let removed = 0;
1183
1208
  for (const id of ids) {
1184
1209
  if (live.has(id)) continue;
1210
+ if (peers.has(id)) continue; // another daemon's tab — not ours to retire
1185
1211
  if (workChains.has(id) || shipping.has(id)) continue; // still draining here
1186
1212
  const wt = join(dir, id);
1187
1213
  try {
@@ -1412,6 +1438,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1412
1438
  if (!plainTab) {
1413
1439
  mint = await mintWorkToken(job.sessionId);
1414
1440
  if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
1441
+ // Another daemon on this credential holds the session. Return
1442
+ // WITHOUT settling: the holder is answering this same turn, and
1443
+ // settling it here — even as a failure — would race the real
1444
+ // answer and could win. Dropping it means the turn stays pending
1445
+ // and the holder's answer lands, which is the whole point.
1446
+ if (mint?.heldElsewhere) {
1447
+ workAnswering.delete(job.id);
1448
+ return;
1449
+ }
1415
1450
  if (mint?.gone) {
1416
1451
  await settleWorkTurn(job.id, {
1417
1452
  ok: false,
@@ -2064,6 +2099,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2064
2099
  processWorkTurns,
2065
2100
  processShipJobs,
2066
2101
  processDiffJobs,
2102
+ heldSessionIds,
2067
2103
  processPreviewJobs,
2068
2104
  livePreviewIds,
2069
2105
  retirePreviews,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.53.0",
3
+ "version": "0.54.1",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {