flowviant 0.102.0 → 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/cli.mjs +8 -0
- package/bin/lib/fleet.mjs +2 -14
- package/bin/lib/git.mjs +32 -1
- package/bin/lib/loginPath.mjs +116 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/views.mjs +23 -0
- package/bin/lib/work.mjs +7 -10
- package/bin/lib/workAgentTurns.mjs +6 -4
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -170,6 +170,8 @@ if (process.argv[2] === 'login') {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
if (process.argv[2] === 'status' && process.argv.includes('--json')) {
|
|
173
|
+
// The tray polls this every 30 s: the fixed per-user bin dirs only, no shell.
|
|
174
|
+
await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess({ shell: false });
|
|
173
175
|
const { desktopStatus, desktopStatusRemote } = await import('./lib/desktopContract.mjs');
|
|
174
176
|
const status = process.argv.includes('--remote') ? await desktopStatusRemote() : desktopStatus();
|
|
175
177
|
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
@@ -178,6 +180,7 @@ if (process.argv[2] === 'status' && process.argv.includes('--json')) {
|
|
|
178
180
|
|
|
179
181
|
// `flowviant status` — the same facts, for a person.
|
|
180
182
|
if (process.argv[2] === 'status') {
|
|
183
|
+
await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
|
|
181
184
|
const { runStatus } = await import('./lib/views.mjs');
|
|
182
185
|
await runStatus();
|
|
183
186
|
process.exit(0);
|
|
@@ -199,6 +202,7 @@ if (process.argv[2] === 'open') {
|
|
|
199
202
|
|
|
200
203
|
// `flowviant doctor` — what this computer needs, checked.
|
|
201
204
|
if (process.argv[2] === 'doctor') {
|
|
205
|
+
await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
|
|
202
206
|
const { runDoctor } = await import('./lib/views.mjs');
|
|
203
207
|
process.exit((await runDoctor()) > 0 ? 1 : 0);
|
|
204
208
|
}
|
|
@@ -561,6 +565,10 @@ if (process.argv[2] && !process.argv[2].startsWith('-')) {
|
|
|
561
565
|
process.exit(1);
|
|
562
566
|
}
|
|
563
567
|
|
|
568
|
+
// Find the CLIs where the person's own terminal would: the Windows tray starts
|
|
569
|
+
// this through `wsl.exe --`, which reads no shell startup file (loginPath.mjs).
|
|
570
|
+
await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
|
|
571
|
+
|
|
564
572
|
// ── WHICH PROJECT THIS START SERVES — said, asked, or refused; never guessed.
|
|
565
573
|
//
|
|
566
574
|
// The store holds many projects since 0.55.0 and resolution is BY REPO
|
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
|
*
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PATH A PERSON'S TERMINAL HAS, for a daemon nobody started from one.
|
|
3
|
+
*
|
|
4
|
+
* Every CLI is found by name on PATH (`claude --version`, `codex`, `agy`,
|
|
5
|
+
* `gh`). A terminal's PATH comes from the shell's startup files; the Windows
|
|
6
|
+
* tray starts the daemon with `wsl.exe -d <distro> -- <binary>`, which reads
|
|
7
|
+
* none of them. That PATH is the distro's bare default, and it misses exactly
|
|
8
|
+
* where the CLIs usually live: `~/.local/bin` (Claude Code's own installer,
|
|
9
|
+
* put on PATH by ~/.profile) and version managers set up in ~/.bashrc (nvm,
|
|
10
|
+
* volta, bun). So a machine whose `claude` works in the owner's WSL terminal
|
|
11
|
+
* measured "not installed" under the tray (2026-09-25).
|
|
12
|
+
*
|
|
13
|
+
* So the daemon asks the person's login shell for its PATH once, and keeps
|
|
14
|
+
* every entry it already had FIRST (a terminal start is unchanged) with the
|
|
15
|
+
* shell's additions after it; then the usual per-user bin directories that
|
|
16
|
+
* exist. Best-effort and bounded: a shell that hangs or prints nothing costs a
|
|
17
|
+
* few seconds once, and nothing is ever removed from PATH.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
21
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
22
|
+
import { homedir, platform } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const MARK = '__FLOWVIANT_PATH__=';
|
|
26
|
+
|
|
27
|
+
/** The PATH an interactive login shell ends up with, or null. */
|
|
28
|
+
export function loginShellPath({ env = process.env, exec = execFileSync, timeoutMs = 4000 } = {}) {
|
|
29
|
+
const shell = env.SHELL && env.SHELL.startsWith('/') ? env.SHELL : '/bin/bash';
|
|
30
|
+
try {
|
|
31
|
+
// -i so ~/.bashrc runs (nvm lives there, behind the "not interactive?
|
|
32
|
+
// return" guard); -l so ~/.profile runs (~/.local/bin lives there). The
|
|
33
|
+
// marker line survives whatever a startup file prints.
|
|
34
|
+
const out = String(exec(shell, ['-ilc', `printf '\\n${MARK}%s\\n' "$PATH"`], {
|
|
35
|
+
env: { ...env, TERM: 'dumb' },
|
|
36
|
+
timeout: timeoutMs,
|
|
37
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
38
|
+
}));
|
|
39
|
+
const line = out.split('\n').reverse().find((l) => l.startsWith(MARK));
|
|
40
|
+
const value = line?.slice(MARK.length).trim();
|
|
41
|
+
return value ? value : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Per-user bin directories CLIs install into, that exist on this box. */
|
|
48
|
+
export function userBinDirs({ home = homedir(), exists = existsSync, list = readdirSync } = {}) {
|
|
49
|
+
const dirs = ['.local/bin', '.npm-global/bin', '.bun/bin', '.volta/bin', '.cargo/bin'].map((d) => join(home, d));
|
|
50
|
+
// nvm: the newest installed node's bin (nvm's own "default" needs its shell
|
|
51
|
+
// function to resolve; the newest is the usual answer and only a fallback).
|
|
52
|
+
try {
|
|
53
|
+
const versions = list(join(home, '.nvm/versions/node'))
|
|
54
|
+
.filter((v) => /^v\d+\.\d+\.\d+$/.test(v))
|
|
55
|
+
.sort((a, b) => {
|
|
56
|
+
const pa = a.slice(1).split('.').map(Number);
|
|
57
|
+
const pb = b.slice(1).split('.').map(Number);
|
|
58
|
+
return pb[0] - pa[0] || pb[1] - pa[1] || pb[2] - pa[2];
|
|
59
|
+
});
|
|
60
|
+
if (versions[0]) dirs.push(join(home, '.nvm/versions/node', versions[0], 'bin'));
|
|
61
|
+
} catch { /* no nvm */ }
|
|
62
|
+
return dirs.filter((d) => exists(d));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Current entries first, then any the others add. Order-preserving, deduped. */
|
|
66
|
+
export function mergePath(current, ...more) {
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const part of [current, ...more].flatMap((p) => (Array.isArray(p) ? p : String(p ?? '').split(':')))) {
|
|
70
|
+
if (!part || seen.has(part)) continue;
|
|
71
|
+
seen.add(part);
|
|
72
|
+
out.push(part);
|
|
73
|
+
}
|
|
74
|
+
return out.join(':');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Widen process.env.PATH in place. `shell: false` skips the login shell and
|
|
79
|
+
* only adds the fixed per-user directories (for the tray's frequent status
|
|
80
|
+
* polls, where a shell start every 30 s is not worth it).
|
|
81
|
+
*/
|
|
82
|
+
export function adoptLoginPath({ shell = true, env = process.env, ...deps } = {}) {
|
|
83
|
+
if (platform() === 'win32' || env.FLOWVIANT_KEEP_PATH === '1') return env.PATH;
|
|
84
|
+
const login = shell ? loginShellPath({ env, ...deps }) : null;
|
|
85
|
+
env.PATH = mergePath(env.PATH, login, userBinDirs(deps));
|
|
86
|
+
return env.PATH;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* adoptLoginPath, for THIS process and every child it will start.
|
|
91
|
+
*
|
|
92
|
+
* Under Bun (the compiled binary the curl install and the tray ship) child
|
|
93
|
+
* processes are looked up and started with the environment the process
|
|
94
|
+
* STARTED with unless a spawn passes `env` — a later `process.env.PATH = …` is
|
|
95
|
+
* invisible to them (measured 2026-09-25: node found ~/.local/bin/claude after
|
|
96
|
+
* the change, the compiled binary did not). So when the PATH grew, the
|
|
97
|
+
* binary restarts itself once with it: a thin proxy, the same shape as the
|
|
98
|
+
* self-update re-exec, stdio inherited, exit code and signals passed through.
|
|
99
|
+
* Node needs none of that and adopts in place.
|
|
100
|
+
*/
|
|
101
|
+
export async function adoptLoginPathForProcess(opts = {}) {
|
|
102
|
+
if (process.env.FLOWVIANT_PATH_ADOPTED === '1') return;
|
|
103
|
+
const before = process.env.PATH;
|
|
104
|
+
const after = adoptLoginPath(opts);
|
|
105
|
+
if (!process.versions.bun || after === before) return;
|
|
106
|
+
const compiled = process.argv[1]?.startsWith('/$bunfs/');
|
|
107
|
+
const child = spawn(process.execPath, process.argv.slice(compiled ? 2 : 1), {
|
|
108
|
+
stdio: 'inherit',
|
|
109
|
+
env: { ...process.env, FLOWVIANT_PATH_ADOPTED: '1' },
|
|
110
|
+
});
|
|
111
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
112
|
+
process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
|
|
113
|
+
}
|
|
114
|
+
const code = await new Promise((resolve) => child.on('exit', (status, signal) => resolve(status ?? (signal ? 1 : 0))));
|
|
115
|
+
process.exit(code);
|
|
116
|
+
}
|
package/bin/lib/shipMerge.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
379
|
-
//
|
|
380
|
-
|
|
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.
|
|
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": {
|