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/README.md +25 -31
- package/bin/cli.mjs +54 -22
- package/bin/lib/config.mjs +0 -21
- package/bin/lib/fleet.mjs +187 -50
- package/bin/lib/git.mjs +1 -1
- package/bin/lib/instance.mjs +691 -17
- package/bin/lib/login.mjs +5 -1
- package/bin/lib/mcp-cli.mjs +12 -11
- package/bin/lib/preflight.mjs +8 -5
- package/bin/lib/runtimes.mjs +12 -3
- package/bin/lib/stream.mjs +10 -8
- package/bin/lib/update.mjs +3 -2
- package/bin/lib/work.mjs +38 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -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
|
|
6
|
-
* tokens the daemon
|
|
7
|
-
*
|
|
8
|
-
* giving THAT principal tools that write to
|
|
9
|
-
* string in a README could file work as
|
|
10
|
-
*
|
|
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
|
|
15
|
-
* the
|
|
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
|
|
81
|
-
console.log('
|
|
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
|
}
|
package/bin/lib/preflight.mjs
CHANGED
|
@@ -91,20 +91,23 @@ export async function preflight({ needGit = true } = {}) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// gh —
|
|
95
|
-
//
|
|
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
|
-
|
|
102
|
-
if (await promptYesNo('Install GitHub CLI (gh) now?',
|
|
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
|
-
:
|
|
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'));
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -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
|
|
691
|
-
//
|
|
692
|
-
//
|
|
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
|
package/bin/lib/stream.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* `{type:'wake'}` frame and the
|
|
7
|
-
* roster fetch — instead of waiting
|
|
8
|
-
* from ≤RECONCILE_SECONDS to ~a
|
|
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 —
|
|
79
|
+
info(c.dim('push channel reconnected — turns start instantly again'));
|
|
78
80
|
} else {
|
|
79
|
-
note(c.dim('push channel connected —
|
|
81
|
+
note(c.dim('push channel connected — turns start instantly'));
|
|
80
82
|
}
|
|
81
83
|
clearPing();
|
|
82
84
|
pingTimer = setInterval(() => {
|
package/bin/lib/update.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|