flowviant 0.102.2 → 0.103.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
@@ -41,19 +41,7 @@ import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs'
41
41
  import { credentialRejected } from './authReject.mjs';
42
42
  import { handleVersionSignal, cmpVersion } from './update.mjs';
43
43
  import { emitMachineEvent, writeDaemonState } from './desktopContract.mjs';
44
- import {
45
- git,
46
- gitNetAsync,
47
- resetWorktree,
48
- repoRootOrDie,
49
- detectBaseRef,
50
- originSlug,
51
- baseBranchName,
52
- isValidPrUrl,
53
- isValidBranch,
54
- isSafePathSegment,
55
- excludeInWorktree,
56
- } from './git.mjs';
44
+ import { git, gitNetAsync, resetWorktree, repoRootOrDie, detectBaseRef, originSlug, baseBranchName, isValidPrUrl, isValidBranch, isSafePathSegment, excludeInWorktree, usableBaseRef } from './git.mjs';
57
45
  import { c, info, note, ok, warn, fail } from './ui.mjs';
58
46
  import { revertPatch, withPatchLock } from './patch.mjs';
59
47
  import {
@@ -2678,7 +2666,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2678
2666
  * thing worse than not offering the choice at all.
2679
2667
  */
2680
2668
  if (typeof roster.baseBranch === 'string' && roster.baseBranch.trim()) {
2681
- const want = `origin/${baseBranchName(roster.baseBranch.trim())}`;
2669
+ const want = usableBaseRef(repoRoot, `origin/${baseBranchName(roster.baseBranch.trim())}`);
2682
2670
  if (want !== baseRef) {
2683
2671
  note(`base · ${want} ${c.dim('(set for this project)')}`);
2684
2672
  baseRef = want;
package/bin/lib/git.mjs CHANGED
@@ -250,12 +250,43 @@ export function detectBaseRef(repoRoot) {
250
250
  }
251
251
  }
252
252
  try {
253
- return `origin/${git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)}`;
253
+ return usableBaseRef(repoRoot, `origin/${git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)}`);
254
254
  } catch {
255
255
  return 'HEAD';
256
256
  }
257
257
  }
258
258
 
259
+ /**
260
+ * A base ref an agent's worktree can actually be cut from.
261
+ *
262
+ * `origin/<branch>` is the right base when the repo HAS an origin. A repo with
263
+ * no remote (a local-only project — the owner's "Death Note - Roblox",
264
+ * 2026-09-25) has no `origin/*` refs at all, and cutting a worktree from one
265
+ * fails; every agent turn then ended "nothing" in a second. So: the ref itself
266
+ * when it resolves to a commit, else the LOCAL branch of the same name, else
267
+ * the ref unchanged (and the worktree step says why in git's words).
268
+ */
269
+ export function usableBaseRef(repoRoot, ref) {
270
+ const resolves = (r) => {
271
+ try { git(['rev-parse', '--verify', '--quiet', `${r}^{commit}`], repoRoot); return true; } catch { return false; }
272
+ };
273
+ if (resolves(ref)) return ref;
274
+ const local = String(ref).replace(/^origin\//, '');
275
+ if (local !== ref && resolves(local)) return local;
276
+ return ref;
277
+ }
278
+
279
+ /** True when the repository has at least one commit (HEAD resolves). */
280
+ export function hasCommits(repoRoot) {
281
+ try { git(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], repoRoot); return true; } catch { return false; }
282
+ }
283
+
284
+ /** The last line git wrote to stderr for a failed call, or its message. */
285
+ export function gitFailure(error) {
286
+ const stderr = String(error?.stderr ?? '').trim().split('\n').filter(Boolean);
287
+ return stderr.at(-1) ?? String(error?.message ?? error).split('\n')[0];
288
+ }
289
+
259
290
  /**
260
291
  * The BRANCH NAME behind a base ref.
261
292
  *
@@ -40,6 +40,34 @@ export function mergeOutward({
40
40
  /* not there — fine */
41
41
  }
42
42
  };
43
+ const hasOrigin = () => {
44
+ try { git(['remote', 'get-url', 'origin'], repoRoot); return true; } catch { return false; }
45
+ };
46
+ /**
47
+ * A REPOSITORY WITH NO REMOTE (2026-09-25). There is nowhere to push, so the
48
+ * merge lands on the local base branch itself. If base is checked out in the
49
+ * project folder it FAST-FORWARDS there, and only over a tree with no
50
+ * uncommitted tracked changes (refused in words otherwise: moving a branch
51
+ * under someone's edits is not a merge anybody approved); if it is not
52
+ * checked out, the ref moves by compare-and-swap. Either way nothing is
53
+ * rewritten, and `--no-ff` above kept every receipt's commit.
54
+ */
55
+ const landLocally = () => {
56
+ const base = baseBranchName(baseRef());
57
+ const merged = String(git(['rev-parse', 'HEAD'], tmpDir)).trim();
58
+ const before = String(git(['rev-parse', `refs/heads/${base}`], repoRoot)).trim();
59
+ let current = null;
60
+ try { current = String(git(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoRoot)).trim(); } catch { /* detached */ }
61
+ if (current === base) {
62
+ const dirty = String(git(['status', '--porcelain', '--untracked-files=no'], repoRoot)).trim();
63
+ if (dirty) {
64
+ throw new Error(`this repository has no remote, so the merge lands on ${base} in ${repoRoot} itself, and that checkout has uncommitted changes. Commit or stash them, then approve again.`);
65
+ }
66
+ git(['merge', '--ff-only', merged], repoRoot);
67
+ } else {
68
+ git(['update-ref', `refs/heads/${base}`, merged, before], repoRoot);
69
+ }
70
+ };
43
71
  const attempt = () => {
44
72
  dropTmp();
45
73
  git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
@@ -47,7 +75,8 @@ export function mergeOutward({
47
75
  ['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
48
76
  tmpDir
49
77
  );
50
- gitNet(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir, 120_000);
78
+ if (hasOrigin()) gitNet(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir, 120_000);
79
+ else landLocally();
51
80
  };
52
81
  try {
53
82
  try {
@@ -132,5 +161,7 @@ export function mergeOutward({
132
161
  */
133
162
  export function isRaceRejection(e) {
134
163
  const d = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
135
- return /non-fast-forward|\[rejected\]|fetch first|stale info/i.test(d);
164
+ // `but expected` is update-ref's compare-and-swap losing (a repo with no
165
+ // remote, where the base ref moved between our read and our write).
166
+ return /non-fast-forward|\[rejected\]|fetch first|stale info|but expected/i.test(d);
136
167
  }
package/bin/lib/views.mjs CHANGED
@@ -194,6 +194,29 @@ export async function runDoctor({ cwd = process.cwd(), fetchImpl = fetch } = {})
194
194
  entry ? `Connected to ${projectLabel(entry)}` : 'This repo is not connected to a project',
195
195
  'Run `flowviant login` here and approve it in your browser.');
196
196
 
197
+ // EVERY COPY ON THIS BOX, and which one a daemon runs from. An old copy with a
198
+ // live daemon is the usual reason the app reports a version older than the
199
+ // one just installed: `uninstall --others` skips a copy a daemon runs from,
200
+ // and a global npm install that needed sudo cannot update itself.
201
+ try {
202
+ const { buildUninstallPlan } = await import('./uninstall.mjs');
203
+ const plan = await buildUninstallPlan();
204
+ const copies = plan.copies.filter((copy) => copy.kind !== 'path-line');
205
+ for (const copy of copies) {
206
+ const where = copy.kind === 'npm-global' ? 'npm global' : copy.kind === 'npx-cache' ? 'npx cache' : 'binary';
207
+ const running = copy.runningPid ? `, a daemon runs from it (pid ${copy.runningPid})` : '';
208
+ const older = copy.version && olderVersion(copy.version, VERSION);
209
+ const text = `flowviant ${copy.version ?? '(unknown version)'} · ${where} · ${copy.path}${copy.current ? ' (this one)' : ''}${running}`;
210
+ if (older && copy.runningPid) {
211
+ add(false, text, `An older daemon is serving. Run \`flowviant stop\`, then \`flowviant uninstall --others\`${copy.kind === 'npm-global' ? ' (if npm refuses, `sudo npm uninstall -g flowviant`)' : ''}, then start flowviant again.`);
212
+ } else if (older) {
213
+ add(false, text, `An older copy. Remove it with \`flowviant uninstall --others\`${copy.kind === 'npm-global' ? ' (if npm refuses, `sudo npm uninstall -g flowviant`)' : ''}.`);
214
+ } else {
215
+ add(true, text);
216
+ }
217
+ }
218
+ } catch { /* the listing is best-effort */ }
219
+
197
220
  const health = String(FLEET_URL).replace(/\/api\/.*$/, '/health');
198
221
  let reach = null;
199
222
  try {
package/bin/lib/work.mjs CHANGED
@@ -48,15 +48,7 @@ import {
48
48
  DAEMON_INSTANCE,
49
49
  MACHINE_HOST,
50
50
  } from './config.mjs';
51
- import {
52
- git,
53
- gitRaw,
54
- gitNet as gitNetIn,
55
- gitNetAsync,
56
- splitNul,
57
- isSafePathSegment,
58
- excludeInWorktree,
59
- } from './git.mjs';
51
+ import { git, gitRaw, gitNet as gitNetIn, gitNetAsync, splitNul, isSafePathSegment, excludeInWorktree, hasCommits, gitFailure } from './git.mjs';
60
52
  import {
61
53
  publishPushArgs,
62
54
  publishFetchArgs,
@@ -1647,7 +1639,12 @@ export function createWorkManager({
1647
1639
  } catch {
1648
1640
  try {
1649
1641
  git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
1650
- } catch {
1642
+ } catch (e) {
1643
+ // Kept for the caller to SAY: an agent turn that cannot get a
1644
+ // worktree used to end "nothing" with no words at all.
1645
+ placeWtFor.lastError = hasCommits(repoRoot)
1646
+ ? `git could not create the worktree from ${at}: ${gitFailure(e)}`
1647
+ : `the repository ${repoRoot} has no commits yet, so there is nothing to branch from. Make a first commit there, then send the agent any message to try again.`;
1651
1648
  return null;
1652
1649
  }
1653
1650
  }
@@ -246,7 +246,7 @@ export function createWorkAgentTurns({
246
246
  const agentId = String(job.agentId || '');
247
247
  const place = String(job.placeId || '');
248
248
  if (!isSafePathSegment(place)) {
249
- await postAgentTurn({ turnId, outcome: 'nothing' });
249
+ await postAgentTurn({ turnId, outcome: 'nothing', answer: 'the machine was handed a turn for a place it will not use as a folder name' });
250
250
  return;
251
251
  }
252
252
  /**
@@ -375,9 +375,11 @@ export function createWorkAgentTurns({
375
375
  }
376
376
  const dir = placeWtFor(place);
377
377
  if (!dir) {
378
- // No worktree and none could be cut. `nothing` rather than an invented
379
- // error: the board says the machine went quiet, which is true.
380
- await postAgentTurn({ turnId, outcome: 'nothing' });
378
+ // No worktree and none could be cut. `nothing`, WITH git's own words:
379
+ // without them the agent sat in Stuck with no question and no reason
380
+ // (the owner, 2026-09-25: "it got stuck without saying why").
381
+ const why = placeWtFor.lastError ?? null;
382
+ await postAgentTurn({ turnId, outcome: 'nothing', ...(why ? { answer: `This machine could not start the turn: ${why}` } : {}) });
381
383
  return;
382
384
  }
383
385
  const wt = dir.wt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.102.2",
3
+ "version": "0.103.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — 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": {